From 330ebddaef831ace65721180b3012b539f581ce6 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 5 May 2026 09:08:08 +0300 Subject: [PATCH 1/6] refactor: refine CLI options and enhance debug logging - Remove --mode, --transition-type, and --decay-index CLI flags (parameters now come from ENSDF data or custom JSON input) - Clarify energy units in all CLI help text (MeV throughout, keV for detector params) - Enhance logging verbosity: - -v (INFO): shows source, component initialization, workflow steps - -vv (DEBUG): shows detailed internals of all spectrum components (parameters, evaluation counts, input/output ranges) - Improve CSV headers with element notation (e.g. Tc99 -> Ru99) - Add CONVENTIONS.md documenting energy unit and logging conventions - Update TODO.md with completion status --- CONVENTIONS.md | 67 ++++++++++- TODO.md | 2 +- beta_spectrum/base.py | 8 ++ beta_spectrum/cli.py | 87 ++++++--------- beta_spectrum/components/exchange.py | 39 ++++++- beta_spectrum/components/fermi.py | 32 +++++- beta_spectrum/components/finite_size.py | 65 ++++++++++- beta_spectrum/components/phase_space.py | 3 + beta_spectrum/components/radiative.py | 35 +++++- beta_spectrum/components/screening.py | 32 +++++- beta_spectrum/spectrum.py | 142 ++++++++++++++++++++++-- notebooks/conftest.py | 1 + scripts/run_notebooks.py | 4 +- tests/test_logging_utils.py | 31 +++++- 14 files changed, 470 insertions(+), 78 deletions(-) diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 2d01cbe..e0a295b 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -66,7 +66,35 @@ from beta_spectrum.constants import ME_MEV, ME_KEV, ALPHA, MP_MEV - Never use manual arithmetic for unit conversion — use `T_to_W()`, `W_to_T()` - Never mix MeV and m_e units in the same calculation -## 3. SpectrumComponent Design +## 3. Energy Unit Convention + +### MeV Throughout + +All energy parameters in the public API use **MeV** as the standard unit: + +- `endpoint_MeV` — always in MeV +- `e_step_MeV` — always in MeV +- `detector_sigma_a_keV` — in keV (detector-specific, exception noted) +- `detector_tau_keV` — in keV (detector-specific, exception noted) + +Detector parameters use keV because detector resolutions are typically specified in keV in the literature. All other energies use MeV. + +### CLI Unit Convention + +All CLI energy parameters use MeV unless explicitly marked otherwise: + +- `--e-step` — MeV (default: 0.001) +- `--sigma` — keV (detector resolution, default: 1.0) +- `--tau` — keV (tail decay constant, default: 5.0) + +### Consistency Rule + +When reading or writing values, always check the unit suffix in the parameter name: +- `_MeV` suffix → value is in MeV +- `_keV` suffix → value is in keV +- No suffix on energy parameter → value is in m_e units (internal) + +## 4. SpectrumComponent Design All spectral components (Fermi function, screening, radiative correction, etc.) follow a common interface: @@ -87,7 +115,42 @@ class MyComponent(SpectrumComponent): return ... ``` -## 4. Physics-Specific Testing Guidelines +## 5. Logging Convention + +### Verbosity Levels + +- **No verbosity (default)**: Only errors and warnings +- **`-v` (INFO)**: Shows source selection, component initialization, workflow steps, and output locations +- **`-vv` (DEBUG)**: Shows all INFO messages plus detailed internals of each spectrum component (parameters, input/output ranges, evaluation counts) + +### Component Logging + +Each `SpectrumComponent` logs at two levels: + +- **INFO** at initialization: Component name and key parameters +- **DEBUG** at evaluation: Number of energy points, input/output value ranges + +This allows debugging without cluttering normal output. + +## 6. Nuclear Data Notation + +### Element and Nuclide Notation + +- Element symbol: `Tc`, `Ru`, `U`, etc. (from atomic number) +- Nuclide notation: `Tc99`, `Ru99`, `U238` (element symbol + mass number) +- Decay notation: `Tc99 -> Ru99` (parent -> daughter) + +### CSV Header Convention + +CSV metadata headers use element notation for readability: + +``` +# nuclide: Tc99 -> Ru99 (Z=43->44) +``` + +This provides both the human-readable notation and the machine-parseable Z values. + +## 7. Physics-Specific Testing Guidelines When writing tests for physics components, verify the following: diff --git a/TODO.md b/TODO.md index 6433a35..db6ffad 100644 --- a/TODO.md +++ b/TODO.md @@ -205,6 +205,6 @@ ______________________________________________________________________ **Version:** 0.3.0\ **Implemented:** Phase space, Fermi function, finite size, screening, exchange, radiative corrections (with delta_cut resummation). Detector response module with analytical models (Gaussian, Gaussian+tail, Tikhonov), convolution API, declarative config integration. χ² curve fitting framework (CurveFitter) with confidence intervals, profile likelihood, and correlation analysis. C(W) shape factor extraction pipeline (CWExtractor) with Kurie plot analysis, parametrized fitting, and g_V/g_A extraction. CLI interface (`bs_pnpi`) with paceENSDF integration, structured logging (-v/-vv/-q), --dry-run, --version, --log-file. CSV metadata headers. JSON input with full detector param support. Comprehensive test suite (195 tests). Notebook quality control with nbmake and auto-save plot hooks. -**Completed:** A2 — detector response function and convolution routines. A4 — fitter routine and C(W) extraction pipeline. B2 — input flexibility (JSON input, ENSDF auto-population). B8 — CLI & output improvements (logging, CSV headers, dry-run, version). +**Completed:** A2 — detector response function and convolution routines. A4 — fitter routine and C(W) extraction pipeline. B2 — input flexibility (JSON input, ENSDF auto-population). B8 — CLI & output improvements (logging, CSV headers, dry-run, version). B8.2 — CLI refinement: removed --mode, --transition-type, --decay-index flags; clarified units (MeV everywhere); enhanced logging (INFO shows components, DEBUG shows internals); CSV headers use element notation (Tc99 -> Ru99). **Next immediate step:** A3 — data processing pipeline for experimental spectra (background subtraction, energy calibration, dead-time correction, pulse pile-up correction). diff --git a/beta_spectrum/base.py b/beta_spectrum/base.py index ed784f4..b48d623 100644 --- a/beta_spectrum/base.py +++ b/beta_spectrum/base.py @@ -23,6 +23,14 @@ class SpectrumComponent(ABC): def __init__(self, logger: Optional["logging.Logger"] = None) -> None: self._logger = logger + self._log_component_info() + + def _log_component_info(self) -> None: + """Log component identification at INFO level.""" + if self._logger is None: + return + class_name = self.__class__.__name__ + self._logger.info("Component initialized: %s", class_name) @abstractmethod def __call__(self, W: ArrayLike) -> ArrayLike: diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index 7987b95..0564cb9 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -9,13 +9,13 @@ Usage examples: bs_pnpi --input custom_input.json --output spectrum.csv # Full analysis with plotting - bs_pnpi --nuclide Co60 --mode BM --plot analysis.png --output spectrum.csv + bs_pnpi --nuclide Co60 --plot analysis.png --output spectrum.csv # With detector response - bs_pnpi --nuclide Tc99 --detector sigma=1.0 --output convolved.csv + bs_pnpi --nuclide Tc99 --detector gaussian --output convolved.csv # Verbose with log file - bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file calc.log + bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file /tmp/calc.log # Dry run to inspect resolved configuration bs_pnpi --nuclide Tc99 --dry-run @@ -42,8 +42,8 @@ def _build_parser() -> argparse.ArgumentParser: " bs_pnpi --nuclide Tc99 --output spectrum.csv\n" " bs_pnpi --input custom_input.json --output spectrum.csv\n" " bs_pnpi --nuclide Tc99 --plot spectrum.png --output spectrum.csv\n" - " bs_pnpi --nuclide Tc99 --detector sigma=1.0 --output convolved.csv\n" - " bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file calc.log\n" + " bs_pnpi --nuclide Tc99 --detector gaussian --output convolved.csv\n" + " bs_pnpi --nuclide Tc99 --output spectrum.csv -vv --log-file /tmp/calc.log\n" " bs_pnpi --nuclide Tc99 --dry-run\n" ), ) @@ -71,31 +71,11 @@ def _build_parser() -> argparse.ArgumentParser: ) # Common parameters - parser.add_argument( - "--mode", - type=str, - default="beta_minus", - choices=["beta_minus", "beta_plus", "ec"], - help="Decay mode for paceENSDF source (default: beta_minus).", - ) - parser.add_argument( - "--decay-index", - type=int, - default=None, - help="Decay index for paceENSDF (0 = ground state, default: auto-select).", - ) - parser.add_argument( - "--transition-type", - type=str, - default=None, - choices=["A", "F1", "F1U", "F2", "F2U", "F3", "F3U", "F4"], - help="Override transition type (forbiddenness classification).", - ) parser.add_argument( "--e-step", type=float, default=0.001, - help="Energy step size in MeV (default: 0.001).", + help="Energy step size in MeV (default: 0.001). All energies are in MeV throughout the toolkit.", ) # Output @@ -122,28 +102,29 @@ def _build_parser() -> argparse.ArgumentParser: default=None, metavar="MODEL", help="Enable detector response convolution. " - "Specify model: 'gaussian', 'gaussian_tail', 'tikhonov', 'tabulated'.", + "Specify model: 'gaussian', 'gaussian_tail', 'tikhonov', 'tabulated'. " + "All detector parameters are in keV.", ) detector_group.add_argument( "--sigma", type=float, default=1.0, metavar="KEV", - help="Resolution parameter sigma in keV (default: 1.0).", + help="Resolution parameter sigma in keV (default: 1.0). Energy-dependent part: sigma(E) = sigma_a + sigma_b * sqrt(E).", ) detector_group.add_argument( "--tau", type=float, default=5.0, metavar="KEV", - help="Tail decay constant tau in keV (default: 5.0).", + help="Low-energy tail decay constant tau in keV (default: 5.0). Only used with 'gaussian_tail' model.", ) detector_group.add_argument( "--tail-fraction", type=float, default=0.0, metavar="FRAC", - help="Low-energy tail fraction (0.0–1.0, default: 0.0).", + help="Low-energy tail fraction (0.0–1.0, default: 0.0). Only used with 'gaussian_tail' model.", ) # Logging and output control @@ -153,7 +134,8 @@ def _build_parser() -> argparse.ArgumentParser: "--verbose", action="count", default=0, - help="Increase verbosity: -v=INFO, -vv=DEBUG (repeatable).", + help="Increase verbosity: -v=INFO (shows source, components, and workflow), " + "-vv=DEBUG (detailed internals of all spectrum components). Repeatable.", ) log_group.add_argument( "-q", @@ -164,8 +146,9 @@ def _build_parser() -> argparse.ArgumentParser: log_group.add_argument( "--log-file", type=str, - metavar="FILE", - help="Append log messages to the specified file.", + metavar="PATH", + help="Write log messages to the specified file path (absolute or relative). " + "Log file is always opened in append mode at DEBUG level.", ) log_group.add_argument( "--dry-run", @@ -228,12 +211,10 @@ def _run(args: argparse.Namespace) -> None: source_type: str if args.nuclide: - logger.info("Source: paceENSDF nuclide=%s mode=%s", args.nuclide, args.mode) + logger.info("Source: paceENSDF nuclide=%s", args.nuclide) config = create_config_from_source( "paceENSDF", nuclide=args.nuclide, - decay_type=args.mode, - decay_index=args.decay_index, e_step_MeV=args.e_step, use_detector_response=detector_kwargs.get("use_detector_response", False), detector_model=detector_kwargs.get("detector_model", "gaussian"), @@ -245,19 +226,12 @@ def _run(args: argparse.Namespace) -> None: elif args.input: logger.info("Source: JSON file=%s", args.input) data = load_json_input(args.input) - if args.transition_type: - data["transition_type"] = args.transition_type config = json_to_config(data) source_type = "json" else: logger.error("No source specified (use --nuclide or --input)") sys.exit(1) - # Override transition type if explicitly provided - if args.transition_type: - config.transition_type = args.transition_type - logger.info("Override transition_type=%s", args.transition_type) - # Dry run: display resolved config and exit if args.dry_run: _print_dry_run_output(config, source_type) @@ -266,15 +240,12 @@ def _run(args: argparse.Namespace) -> None: # Create and calculate spectrum start_time = time.time() logger.info( - "Creating BetaSpectrum for %s -> Z=%d,%d, A=%d, endpoint=%.3f MeV", - _element_symbol(config.Z_parent), - config.Z_parent, - config.Z_daughter, - config.A_number, + "Creating BetaSpectrum for %s, endpoint=%.3f MeV", + _decay_notation(config.Z_parent, config.Z_daughter, config.A_number), config.endpoint_MeV, ) - spectrum = BetaSpectrum.from_config(config) + spectrum = BetaSpectrum.from_config(config, logger=logger) _W, _kinetic_MeV = spectrum.get_energy_grid(config) _ = spectrum(_W) @@ -285,13 +256,13 @@ def _run(args: argparse.Namespace) -> None: # Export to CSV if args.output: - analyzer = BetaSpectrumAnalyzer(spectrum, config) + analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger) analyzer.export_to_csv(args.output, source_type=source_type) logger.info("Spectrum exported to %s", args.output) # Generate plot if args.plot: - analyzer = BetaSpectrumAnalyzer(spectrum, config) + analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger) analyzer.plot_analysis(save_path=args.plot) logger.info("Analysis plot saved to %s", args.plot) @@ -322,7 +293,9 @@ def _print_dry_run_output(config, source_type: str) -> None: print(f"Package: beta-spectrum v{__import__('beta_spectrum').__version__}") print(f"Git commit: {get_git_short_hash()}") print(f"Source: {source_type}") - print(f"Nuclide: {config.Z_parent} -> {config.Z_daughter}, A={config.A_number}") + print( + f"Nuclide: {_decay_notation(config.Z_parent, config.Z_daughter, config.A_number)}" + ) print(f"Endpoint: {config.endpoint_MeV * 1000:.1f} keV") print(f"Transition: {config.transition_type}") print(f"e_step: {config.e_step_MeV:.4f} MeV") @@ -445,6 +418,16 @@ def _element_symbol(Z: int) -> str: return symbols[Z] if Z < len(symbols) else f"Z{Z}" +def _nuclide_symbol(Z: int, A: int) -> str: + """Get nuclide notation like 'Tc99' from atomic and mass numbers.""" + return f"{_element_symbol(Z)}{A}" + + +def _decay_notation(Z_parent: int, Z_daughter: int, A: int) -> str: + """Get decay notation like 'Tc99 -> Ru99' from atomic and mass numbers.""" + return f"{_nuclide_symbol(Z_parent, A)} -> {_nuclide_symbol(Z_daughter, A)}" + + def main() -> None: """Main entry point for the CLI.""" parser = _build_parser() diff --git a/beta_spectrum/components/exchange.py b/beta_spectrum/components/exchange.py index 73d5c6c..1e75d7c 100644 --- a/beta_spectrum/components/exchange.py +++ b/beta_spectrum/components/exchange.py @@ -1,5 +1,7 @@ from pathlib import Path -from typing import Dict +from typing import Dict, Optional + +import logging import numpy as np @@ -11,7 +13,14 @@ class ExchangeCorrection(SpectrumComponent): Atomic xchange correction using Hayen 2018 Table X coefficients (App. G) """ - def __init__(self, Z: int, filename: str | None = None, eps: float = 1e-6): + def __init__( + self, + Z: int, + filename: Optional[str] = None, + eps: float = 1e-6, + logger: Optional[logging.Logger] = None, + ): + super().__init__(logger=logger) self.Z = Z self.eps = eps @@ -20,6 +29,13 @@ class ExchangeCorrection(SpectrumComponent): self.coeffs = self._load_coeffs(filename) + if self._logger: + self._logger.debug( + "ExchangeCorrection: Z=%d, coefficients loaded from %s", + Z, + filename, + ) + def _load_coeffs(self, filename: str) -> Dict[str, float]: data = np.genfromtxt(filename, delimiter=",", names=True) @@ -42,6 +58,14 @@ class ExchangeCorrection(SpectrumComponent): return Path(__file__).resolve().parent.parent / "data" / "exchange_coeff.csv" def __call__(self, W: np.ndarray) -> np.ndarray: + if self._logger: + self._logger.debug( + "ExchangeCorrection: evaluating at %d points, W range=[%.4f, %.4f]", + len(W), + W.min(), + W.max(), + ) + W = np.asarray(W) # Define physical cutoff @@ -71,4 +95,13 @@ class ExchangeCorrection(SpectrumComponent): X_safe = 1.0 + term1 + term2 + term3 + term4 - return np.asarray(X_safe, dtype=np.float64) + result = np.asarray(X_safe, dtype=np.float64) + + if self._logger: + self._logger.debug( + "ExchangeCorrection: output range=[%.6e, %.6e]", + result.min(), + result.max(), + ) + + return result diff --git a/beta_spectrum/components/fermi.py b/beta_spectrum/components/fermi.py index d63f6b5..bb48972 100644 --- a/beta_spectrum/components/fermi.py +++ b/beta_spectrum/components/fermi.py @@ -1,4 +1,5 @@ # components/fermi.py +import logging import numpy as np from scipy.special import loggamma @@ -8,14 +9,32 @@ from ..utils import nuclear_radius class FermiFunction(SpectrumComponent): - def __init__(self, Z: int, A: int): + def __init__(self, Z: int, A: int, logger: logging.Logger | None = None): + super().__init__(logger=logger) self.Z = Z self.A = A self.alphaZ = ALPHA * Z self.gamma = np.sqrt(1.0 - self.alphaZ**2) self.R = nuclear_radius(A) + if self._logger: + self._logger.debug( + "FermiFunction: Z=%d, A=%d, alpha*Z=%.4f, gamma=%.6f, R=%.4f", + Z, + A, + self.alphaZ, + self.gamma, + self.R, + ) def __call__(self, W: np.ndarray) -> np.ndarray: + if self._logger: + self._logger.debug( + "FermiFunction: evaluating at %d points, W range=[%.4f, %.4f]", + len(W), + W.min(), + W.max(), + ) + p = np.sqrt(W**2 - 1.0) # Avoid division by zero @@ -43,4 +62,13 @@ class FermiFunction(SpectrumComponent): logF = log_prefactor + log_power + log_gamma_abs_sq - log_gamma_denom + log_exp - return np.asarray(np.exp(logF), dtype=np.float64) + result = np.asarray(np.exp(logF), dtype=np.float64) + + if self._logger: + self._logger.debug( + "FermiFunction: output range=[%.6e, %.6e]", + result.min(), + result.max(), + ) + + return result diff --git a/beta_spectrum/components/finite_size.py b/beta_spectrum/components/finite_size.py index 8648e34..47d8b25 100644 --- a/beta_spectrum/components/finite_size.py +++ b/beta_spectrum/components/finite_size.py @@ -1,3 +1,5 @@ +import logging + import numpy as np from beta_spectrum.constants import ALPHA @@ -13,7 +15,8 @@ class FiniteSizeL0(SpectrumComponent): Includes (1 + gamma)/2 prefactor """ - def __init__(self, Z: int, A: int): + def __init__(self, Z: int, A: int, logger: logging.Logger | None = None): + super().__init__(logger=logger) self.Z = Z self.A = A @@ -25,20 +28,74 @@ class FiniteSizeL0(SpectrumComponent): # beta-minus decay self.sign = -1 + if self._logger: + self._logger.debug( + "FiniteSizeL0: Z=%d, A=%d, R=%.4f, alphaZ=%.4f, gamma=%.6f", + Z, + A, + self.R, + self.alphaZ, + self.gamma, + ) + def __call__(self, W: np.ndarray) -> np.ndarray: + if self._logger: + self._logger.debug( + "FiniteSizeL0: evaluating at %d points, W range=[%.4f, %.4f]", + len(W), + W.min(), + W.max(), + ) + # Terms from expansion term1 = self.sign * self.alphaZ * W * self.R term2 = (13 / 60) * self.alphaZ**2 term3 = -0.5 * (self.alphaZ * self.R) / W - return 1 + term1 + term2 + term3 + result = 1 + term1 + term2 + term3 + + if self._logger: + self._logger.debug( + "FiniteSizeL0: output range=[%.6e, %.6e]", + result.min(), + result.max(), + ) + + return result class ChargeDistributionU(SpectrumComponent): - def __init__(self, Z: int, A: int): + def __init__(self, Z: int, A: int, logger: logging.Logger | None = None): + super().__init__(logger=logger) self.Z = Z self.R = nuclear_radius(A) self.alphaZ = ALPHA * Z + if self._logger: + self._logger.debug( + "ChargeDistributionU: Z=%d, A=%d, R=%.4f, alphaZ=%.4f", + Z, + A, + self.R, + self.alphaZ, + ) + def __call__(self, W: np.ndarray) -> np.ndarray: - return 1.0 + (1 / 5) * (self.alphaZ * W * self.R) ** 2 + if self._logger: + self._logger.debug( + "ChargeDistributionU: evaluating at %d points, W range=[%.4f, %.4f]", + len(W), + W.min(), + W.max(), + ) + + result = 1.0 + (1 / 5) * (self.alphaZ * W * self.R) ** 2 + + if self._logger: + self._logger.debug( + "ChargeDistributionU: output range=[%.6e, %.6e]", + result.min(), + result.max(), + ) + + return result diff --git a/beta_spectrum/components/phase_space.py b/beta_spectrum/components/phase_space.py index 5eaee28..669cd42 100644 --- a/beta_spectrum/components/phase_space.py +++ b/beta_spectrum/components/phase_space.py @@ -15,6 +15,7 @@ class PhaseSpace(SpectrumComponent): transition_type: str = "A", m_e: float = 1.0, m_nu: float = 0.0, + logger=None, ): """ Create instance of phase space with given decay parameters. @@ -25,7 +26,9 @@ class PhaseSpace(SpectrumComponent): transition_type: allowed or N-order forbidden, should be [A, F1, F1U, F2, F2U, F3, F3U, F4] m_e: 1st body mass (electron by default: m_e = 1) m_nu:2nd body mass (massless neutrino by default: m_nu = 0) + logger: Optional logger for debug/info output """ + super().__init__(logger=logger) self.tr_type = transition_type if self.tr_type not in ["A", "F1", "F1U", "F2", "F2U", "F3", "F3U", "F4"]: raise ValueError( diff --git a/beta_spectrum/components/radiative.py b/beta_spectrum/components/radiative.py index 176a626..2c979a2 100644 --- a/beta_spectrum/components/radiative.py +++ b/beta_spectrum/components/radiative.py @@ -1,4 +1,6 @@ # components/radiative.py +import logging + import numpy as np from scipy.special import spence @@ -41,6 +43,7 @@ class RadiativeCorrection(SpectrumComponent): A: int | None = None, use_endpoint_resummation: bool = True, delta_cut: float = 1e-3, + logger: logging.Logger | None = None, ): """ Args: @@ -49,7 +52,9 @@ class RadiativeCorrection(SpectrumComponent): A: Mass number (optional, affects nuclear model correction) use_endpoint_resummation: Apply Eq.(53) to handle ln(W0 - W) divergence delta_cut: Only apply resummation when (W0 - W) < delta_cut (spec Section 3.4) + logger: Optional logger for debug/info output """ + super().__init__(logger=logger) self.W0 = W0 self.Z = Z self.A = A @@ -65,6 +70,16 @@ class RadiativeCorrection(SpectrumComponent): # Precompute O(Z*alpha^2) energy-independent nuclear part if A is given self._delta_F = self._compute_nuclear_model_correction(Z, A) + if self._logger: + self._logger.debug( + "RadiativeCorrection: W0=%.4f, Z=%d, A=%s, delta_cut=%.2e, resummation=%s", + W0, + Z, + A, + delta_cut, + use_endpoint_resummation, + ) + def __call__(self, W: np.ndarray) -> np.ndarray: """ Calculate R(W, W0) = 1 + delta_1(W, W0) + delta_2(Z, W). @@ -75,6 +90,15 @@ class RadiativeCorrection(SpectrumComponent): Returns: Radiative correction factor array """ + if self._logger: + self._logger.debug( + "RadiativeCorrection: evaluating at %d points, W range=[%.4f, %.4f], W0=%.4f", + len(W), + W.min(), + W.max(), + self.W0, + ) + W = np.asarray(W, dtype=float) # Avoid numerical issues exactly at endpoint W0 @@ -95,7 +119,16 @@ class RadiativeCorrection(SpectrumComponent): delta_R_finite = delta_1 + delta_2 delta_R[mask_finite] = delta_R_finite - return np.asarray(1.0 + delta_R, dtype=np.float64) + result = np.asarray(1.0 + delta_R, dtype=np.float64) + + if self._logger: + self._logger.debug( + "RadiativeCorrection: output range=[%.6e, %.6e]", + result.min(), + result.max(), + ) + + return result def _delta_1(self, W: np.ndarray) -> np.ndarray: """ diff --git a/beta_spectrum/components/screening.py b/beta_spectrum/components/screening.py index 9571ac5..12d9d40 100644 --- a/beta_spectrum/components/screening.py +++ b/beta_spectrum/components/screening.py @@ -1,5 +1,7 @@ from typing import Any +import logging + import numpy as np from beta_spectrum.base import SpectrumComponent @@ -26,6 +28,7 @@ class ScreeningCorrection(SpectrumComponent): Delta: float = 0.01, eps: float = 1e-6, C: float = 0.015, + logger: logging.Logger | None = None, ): """ Parameters @@ -39,6 +42,7 @@ class ScreeningCorrection(SpectrumComponent): Delta : switching width eps : numerical floor for W_tilde """ + super().__init__(logger=logger) self.fermi = fermi_function self.Z = fermi_function.Z self.Ws = Ws @@ -47,10 +51,27 @@ class ScreeningCorrection(SpectrumComponent): self._C = C self.V0 = V0 if V0 is not None else self._estimate_V0() + if self._logger: + self._logger.debug( + "ScreeningCorrection: Z=%d, V0=%.6f, Ws=%.4f, Delta=%.4f", + self.Z, + self.V0, + self.Ws, + self.Delta, + ) + # -------------------- # Core evaluation # -------------------- def __call__(self, W: np.ndarray) -> np.ndarray: + if self._logger: + self._logger.debug( + "ScreeningCorrection: evaluating at %d points, W range=[%.4f, %.4f]", + len(W), + W.min(), + W.max(), + ) + W = np.asarray(W) # Energy shift by screening potential V0 @@ -70,7 +91,16 @@ class ScreeningCorrection(SpectrumComponent): # Smooth switching f = self._switching_function(W) - return np.asarray(1.0 + f * (S_raw - 1.0), dtype=np.float64) + result = np.asarray(1.0 + f * (S_raw - 1.0), dtype=np.float64) + + if self._logger: + self._logger.debug( + "ScreeningCorrection: output range=[%.6e, %.6e]", + result.min(), + result.max(), + ) + + return result # -------------------- # Helpers diff --git a/beta_spectrum/spectrum.py b/beta_spectrum/spectrum.py index e59107a..22adb19 100644 --- a/beta_spectrum/spectrum.py +++ b/beta_spectrum/spectrum.py @@ -94,6 +94,10 @@ class BetaSpectrum: """ result: np.ndarray = np.ones_like(W, dtype=float) for comp in self.components: + if self._logger: + self._logger.debug( + "Evaluating %s at %d energy points", comp.__class__.__name__, len(W) + ) result = result * comp(W) return result.astype(np.float64) @@ -131,29 +135,42 @@ class BetaSpectrum: W0 = float(T_to_W(config.endpoint_MeV)) if config.use_phase_space: - components.append(PhaseSpace(W0=W0, transition_type=config.transition_type)) + components.append( + PhaseSpace(W0=W0, transition_type=config.transition_type, logger=logger) + ) if config.use_fermi: - components.append(FermiFunction(Z=config.Z_daughter, A=config.A_number)) + components.append( + FermiFunction(Z=config.Z_daughter, A=config.A_number, logger=logger) + ) if config.use_finite_size: - components.append(FiniteSizeL0(Z=config.Z_daughter, A=config.A_number)) + components.append( + FiniteSizeL0(Z=config.Z_daughter, A=config.A_number, logger=logger) + ) if config.use_charge_dist: components.append( - ChargeDistributionU(Z=config.Z_daughter, A=config.A_number) + ChargeDistributionU( + Z=config.Z_daughter, A=config.A_number, logger=logger + ) ) if config.use_screening: components.append( - ScreeningCorrection(FermiFunction(Z=config.Z_parent, A=config.A_number)) + ScreeningCorrection( + FermiFunction(Z=config.Z_parent, A=config.A_number, logger=logger), + logger=logger, + ) ) if config.use_exchange: - components.append(ExchangeCorrection(Z=config.Z_parent)) + components.append(ExchangeCorrection(Z=config.Z_parent, logger=logger)) if config.use_radiative: - components.append(RadiativeCorrection(W0=W0, use_endpoint_resummation=True)) + components.append( + RadiativeCorrection(W0=W0, use_endpoint_resummation=True, logger=logger) + ) return cls(components, logger=logger) @@ -663,11 +680,120 @@ class BetaSpectrumAnalyzer: # Build metadata header from datetime import datetime, timezone + def _element_symbol(Z: int) -> str: + symbols = [ + "", + "H", + "He", + "Li", + "Be", + "B", + "C", + "N", + "O", + "F", + "Ne", + "Na", + "Mg", + "Al", + "Si", + "P", + "S", + "Cl", + "Ar", + "K", + "Ca", + "Sc", + "Ti", + "V", + "Cr", + "Mn", + "Fe", + "Co", + "Ni", + "Cu", + "Zn", + "Ga", + "Ge", + "As", + "Se", + "Br", + "Kr", + "Rb", + "Sr", + "Y", + "Zr", + "Nb", + "Mo", + "Tc", + "Ru", + "Rh", + "Pd", + "Ag", + "Cd", + "In", + "Sn", + "Sb", + "Te", + "I", + "Xe", + "Cs", + "Ba", + "La", + "Ce", + "Pr", + "Nd", + "Pm", + "Sm", + "Eu", + "Gd", + "Tb", + "Dy", + "Ho", + "Er", + "Tm", + "Yb", + "Lu", + "Hf", + "Ta", + "W", + "Re", + "Os", + "Ir", + "Pt", + "Au", + "Hg", + "Tl", + "Pb", + "Bi", + "Po", + "At", + "Rn", + "Fr", + "Ra", + "Ac", + "Th", + "Pa", + "U", + "Np", + "Pu", + "Am", + "Cm", + "Bk", + "Cf", + "Es", + "Fm", + ] + return symbols[Z] if Z < len(symbols) else f"Z{Z}" + + parent_symbol = _element_symbol(self.config.Z_parent) + daughter_symbol = _element_symbol(self.config.Z_daughter) + header_lines = [ f"# beta-spectrum v{__import__('beta_spectrum').__version__}", f"# timestamp: {datetime.now(timezone.utc).isoformat()}", f"# source: {source_type}", - f"# nuclide: {self.config.Z_parent}->{self.config.Z_daughter}, A={self.config.A_number}", + f"# nuclide: {parent_symbol}{self.config.A_number} -> {daughter_symbol}{self.config.A_number} (Z={self.config.Z_parent}->{self.config.Z_daughter})", f"# endpoint: {self.config.endpoint_MeV * 1000:.1f} keV", f"# transition: {self.config.transition_type}", f"# corrections: {', '.join(enabled)}", diff --git a/notebooks/conftest.py b/notebooks/conftest.py index 0d84d19..aaaf6dd 100644 --- a/notebooks/conftest.py +++ b/notebooks/conftest.py @@ -1,5 +1,6 @@ # conftest.py — shared fixtures for notebooks import matplotlib + matplotlib.use("Agg") # non-interactive backend for CI import matplotlib.pyplot as plt diff --git a/scripts/run_notebooks.py b/scripts/run_notebooks.py index 7ffd4c2..08e9cea 100644 --- a/scripts/run_notebooks.py +++ b/scripts/run_notebooks.py @@ -98,7 +98,9 @@ def main(): failed.append(nb.name) print() - print(f"Done. {len(all_plots)} plot(s) extracted, {len(failed)} notebook(s) failed.") + print( + f"Done. {len(all_plots)} plot(s) extracted, {len(failed)} notebook(s) failed." + ) if all_plots: print() print("Plots saved to: notebooks/_plots/") diff --git a/tests/test_logging_utils.py b/tests/test_logging_utils.py index 6b3c429..78399a4 100644 --- a/tests/test_logging_utils.py +++ b/tests/test_logging_utils.py @@ -217,7 +217,8 @@ class TestCSVMetadataHeader: with open(filename, "r") as f: content = f.read() - assert "# nuclide: 43->44, A=99" in content + assert "# nuclide: Tc99 -> Ru99" in content + assert "Z=43->44" in content os.unlink(filename) def test_header_contains_git_commit(self): @@ -316,9 +317,9 @@ class TestCLILogging: parser = _build_parser() args = parser.parse_args( - ["--nuclide", "Tc99", "--dry-run", "--log-file", "test.log"] + ["--nuclide", "Tc99", "--dry-run", "--log-file", "/tmp/test.log"] ) - assert args.log_file == "test.log" + assert args.log_file == "/tmp/test.log" def test_dry_run_flag(self, capsys): """Test that --dry-run is recognized.""" @@ -336,3 +337,27 @@ class TestCLILogging: with pytest.raises(SystemExit) as exc_info: parser.parse_args(["--version"]) assert exc_info.value.code == 0 + + def test_removed_mode_flag(self): + """Test that --mode option has been removed.""" + from beta_spectrum.cli import _build_parser + + parser = _build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--nuclide", "Tc99", "--mode", "beta_minus"]) + + def test_removed_transition_type_flag(self): + """Test that --transition-type option has been removed.""" + from beta_spectrum.cli import _build_parser + + parser = _build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--nuclide", "Tc99", "--transition-type", "A"]) + + def test_removed_decay_index_flag(self): + """Test that --decay-index option has been removed.""" + from beta_spectrum.cli import _build_parser + + parser = _build_parser() + with pytest.raises(SystemExit): + parser.parse_args(["--nuclide", "Tc99", "--decay-index", "0"]) From 297778d78027eba9bc8a47ed6a41c33ee093362f Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 5 May 2026 10:48:53 +0300 Subject: [PATCH 2/6] Add ./output/ directory for debug verification artifacts and update development workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create ./output/ with .gitkeep for test run outputs - Add output/* to .gitignore (negated .gitkeep) - Add §4.5 Debug Verification to DEVELOPMENT.md: final step of each dev iteration - Run bs_pnpi -vv with full parameter consistency checks - Required debug logging per component documented - Example commands and success criteria - Update Agent Instructions and Release Checklist - Mark B8.3 debug verification framework as completed in TODO.md --- .gitignore | 2 + DEVELOPMENT.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++ TODO.md | 4 +- output/.gitkeep | 0 4 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 output/.gitkeep diff --git a/.gitignore b/.gitignore index ed10be3..f43c0da 100644 --- a/.gitignore +++ b/.gitignore @@ -81,6 +81,8 @@ ipython_config.py # data/ outputs/ results/ +output/* +!output/.gitkeep *.npy *.npz *.h5 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f217f7e..aaedb62 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -111,6 +111,103 @@ Notebooks in `notebooks/` are executed as pytest tests using `nbmake`. This ensu pytest notebooks/ --nbmake # or just `pytest` (includes notebooks/) ``` +______________________________________________________________________ + +### 4.5. Debug Verification (Final Step of Each Dev Iteration) + +After all quality gates pass, run a **debug-level verification** as the final step of every development iteration. This catches configuration mismatches, missing parameter propagation, and silent logic errors before merging. + +**Purpose:** Verify that all parameters (endpoint energy, energy grid, Z, A, physical constants) are consistent throughout the pipeline, and that every spectrum component emits sufficient debug logging for explicit comparison. + +**Procedure:** + +1. **Run `bs_pnpi` with `-vv` (DEBUG logging) and output to `./output/`:** + ```bash + bs_pnpi --nuclide Tc99 -vv --output ./output/verification.csv + ``` + +2. **Capture full debug output to a log file for analysis:** + ```bash + bs_pnpi --nuclide Tc99 -vv --output ./output/verification.csv --log-file ./output/debug.log 2>&1 | tee ./output/debug_stdout.log + ``` + +3. **Verify consistency of key parameters in the debug output:** + + | Parameter | What to check | Where to find it | + |---|---|---| + | `endpoint_MeV` | Matches expected Q-value | CLI dry-run, config creation, energy grid | + | `Z_parent` / `Z_daughter` | Consistent across all components | Component init logs | + | `A_number` | Consistent across all components | Component init logs | + | `e_step_MeV` | Matches grid size × (endpoint - e_step) | Energy grid creation | + | `W0` (endpoint in natural units) | `W0 = 1.0 + endpoint_MeV / m_e` | PhaseSpace init | + | Physical constants | `m_e = 0.51099895` MeV, `α = 1/137.036` | Constants module, Fermi function | + | Energy grid bounds | `e_step` to `endpoint - e_step` | `get_energy_grid()` output | + +4. **Verify each component's debug logging:** + + Every enabled component must log its initialization parameters and a representative evaluation. Check the debug output for entries like: + + ``` + DEBUG ... Initializing PhaseSpace: W0=1.57664, transition_type=A, m_e=1.0 + DEBUG ... Evaluating PhaseSpace at 294 energy points + DEBUG ... PhaseSpace range: [min=..., max=...] + ``` + + Required log entries per component: + + | Component | Must log | + |---|---| + | `PhaseSpace` | `W0`, `transition_type`, `m_e`, `m_nu`, energy range | + | `FermiFunction` | `Z`, `A`, `α`, Coulomb approximation used | + | `FiniteSizeL0` | `Z`, `A`, nuclear radius `R`, L0 approximation | + | `ChargeDistributionU` | `Z`, `A`, charge radius, U correction range | + | `ScreeningCorrection` | `Z_parent`, screening model, S(Z,W) range | + | `ExchangeCorrection` | `Z_parent`, Hayen coefficients used, X(Z,W) range | + | `RadiativeCorrection` | `W0`, `δ_r` formula, resummation flag, `delta_cut` value | + +5. **Report any missing logging:** + + If a component's debug output does not allow explicit verification of its parameters or computed range, **add logging to that component** before the iteration is considered complete. The goal is that a developer (or an LLM) reading the debug log can reconstruct every parameter and verify correctness without reading source code. + +6. **Save debug artifacts to `./output/`:** + + After verification, ensure all artifacts are in `./output/`: + - `debug_stdout.log` — full stdout+stderr from `bs_pnpi -vv` + - `debug.log` — log file output (if `--log-file` was used) + - `verification.csv` — the spectrum CSV with metadata header + - `verification.png` — analysis plot (if `--plot` was used) + + These files serve as a **verifiable record** of the last working state and can be used for regression comparison. + +**Example verification command:** + +```bash +# Full debug run with all outputs +bs_pnpi --nuclide Tc99 -vv \ + --output ./output/verification.csv \ + --plot ./output/verification.png \ + --log-file ./output/debug.log 2>&1 | tee ./output/debug_stdout.log + +# Quick parameter extraction from debug log +grep -E '(Initializing|Evaluating|W0=|Z=|endpoint|e_step|PhaseSpace|Fermi|FiniteSize|Screening|Exchange|Radiative)' ./output/debug_stdout.log +``` + +**Success criteria:** + +- [ ] All parameters (Z, A, endpoint, e_step, W0) appear consistently in debug output +- [ ] Each enabled component logs its initialization parameters +- [ ] Each enabled component logs its evaluation energy range +- [ ] Physical constants match expected values +- [ ] No silent parameter coercion or unit conversion errors detected +- [ ] All artifacts saved to `./output/` + +**If any check fails:** + +1. Add missing debug logging to the component +2. Fix any parameter mismatch +3. Re-run the verification until all checks pass +4. Only then consider the iteration complete + ## 5. Agent Instructions When working on this project, agents must: @@ -123,6 +220,7 @@ When working on this project, agents must: 6. **Work on a dev branch** — create `dev/` and switch to it 7. **Run quality gates** after making changes and report results 8. **Update docs** when changing public API or adding features +9. **Run debug verification** (`bs_pnpi -vv`) as the final step of each iteration, saving all artifacts to `./output/` and verifying parameter consistency across all components ## 6. Release Checklist @@ -130,6 +228,8 @@ When working on this project, agents must: - [ ] Code is formatted (`black --check`) - [ ] No lint errors (`ruff check`) - [ ] Type checks pass (`mypy`) +- [ ] Debug verification passes (`bs_pnpi -vv`) — all parameters consistent, all components logged +- [ ] Debug artifacts saved to `./output/` (debug_stdout.log, verification.csv, etc.) - [ ] Update version in `pyproject.toml` - [ ] Update `Development Status` section in `README.md` - [ ] Update `TODO.md` marking corresponding items as complete. diff --git a/TODO.md b/TODO.md index db6ffad..c65bf0f 100644 --- a/TODO.md +++ b/TODO.md @@ -178,6 +178,8 @@ ______________________________________________________________________ - [x] Add `--dry-run` option to validate input and display resolved parameters without calculation - [x] Add `--version` flag - [x] Add `-q/--quiet` flag to suppress terminal output +- [x] Add `./output/` directory for debug verification artifacts (gitignored except `.gitkeep`) +- [x] Add debug verification step to development workflow: `bs_pnpi -vv` with parameter consistency checks across all components, documented in DEVELOPMENT.md §4.5 ______________________________________________________________________ @@ -205,6 +207,6 @@ ______________________________________________________________________ **Version:** 0.3.0\ **Implemented:** Phase space, Fermi function, finite size, screening, exchange, radiative corrections (with delta_cut resummation). Detector response module with analytical models (Gaussian, Gaussian+tail, Tikhonov), convolution API, declarative config integration. χ² curve fitting framework (CurveFitter) with confidence intervals, profile likelihood, and correlation analysis. C(W) shape factor extraction pipeline (CWExtractor) with Kurie plot analysis, parametrized fitting, and g_V/g_A extraction. CLI interface (`bs_pnpi`) with paceENSDF integration, structured logging (-v/-vv/-q), --dry-run, --version, --log-file. CSV metadata headers. JSON input with full detector param support. Comprehensive test suite (195 tests). Notebook quality control with nbmake and auto-save plot hooks. -**Completed:** A2 — detector response function and convolution routines. A4 — fitter routine and C(W) extraction pipeline. B2 — input flexibility (JSON input, ENSDF auto-population). B8 — CLI & output improvements (logging, CSV headers, dry-run, version). B8.2 — CLI refinement: removed --mode, --transition-type, --decay-index flags; clarified units (MeV everywhere); enhanced logging (INFO shows components, DEBUG shows internals); CSV headers use element notation (Tc99 -> Ru99). +**Completed:** A2 — detector response function and convolution routines. A4 — fitter routine and C(W) extraction pipeline. B2 — input flexibility (JSON input, ENSDF auto-population). B8 — CLI & output improvements (logging, CSV headers, dry-run, version). B8.2 — CLI refinement: removed --mode, --transition-type, --decay-index flags; clarified units (MeV everywhere); enhanced logging (INFO shows components, DEBUG shows internals); CSV headers use element notation (Tc99 -> Ru99). B8.3 — debug verification framework: `./output/` directory for artifacts, development workflow step 4.5 with `-vv` parameter consistency checks across all components. **Next immediate step:** A3 — data processing pipeline for experimental spectra (background subtraction, energy calibration, dead-time correction, pulse pile-up correction). diff --git a/output/.gitkeep b/output/.gitkeep new file mode 100644 index 0000000..e69de29 From 3749220efc8b76b33971d5a279360c9f3b024c62 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 5 May 2026 12:43:43 +0300 Subject: [PATCH 3/6] Fix pyright type errors and correct paceENSDF Q-value extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nuclear_data.py: Extract Q-value from ground state branch state[7] instead of halflife_data (was reading half-life seconds as Q-value) - nuclear_data.py: Use ground state branch for forbiddenness code, with fallback - nuclear_data.py: Add None guards for beta_data and pairs - spectrum.py: Pass Z=config.Z_daughter, A=config.A_number to RadiativeCorrection - phase_space.py: Add __init__ debug logging (W0, transition_type, m_e, m_nu) - phase_space.py: Add output range logging in __call__ - phase_space.py: Fix possibly unbound forbid_factor variable - logging_utils.py: Fix broken get_git_short_hash indentation - cli.py: Add explicit SpectrumConfig annotation to fix pyright type identity - cw_extractor.py: Fix T_to_W ndarray→float conversions (6 locations) - cw_extractor.py: Fix FermiFunction() calls — was passing Z as __call__ arg - fitter.py: Add explicit dict type annotation for least_squares kwargs --- beta_spectrum/cli.py | 2 ++ beta_spectrum/components/phase_space.py | 20 +++++++---- beta_spectrum/cw_extractor.py | 26 +++++++------- beta_spectrum/fitter.py | 26 +++++++------- beta_spectrum/logging_utils.py | 4 +-- beta_spectrum/nuclear_data.py | 48 +++++++++++++++++++------ beta_spectrum/spectrum.py | 8 ++++- 7 files changed, 89 insertions(+), 45 deletions(-) diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index 0564cb9..928b72b 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -28,6 +28,7 @@ import sys from typing import Any, Dict from beta_spectrum.logging_utils import LoggingConfig, setup_logging +from beta_spectrum.spectrum import SpectrumConfig def _build_parser() -> argparse.ArgumentParser: @@ -210,6 +211,7 @@ def _run(args: argparse.Namespace) -> None: detector_kwargs = _parse_detector_args(args) source_type: str + config: SpectrumConfig if args.nuclide: logger.info("Source: paceENSDF nuclide=%s", args.nuclide) config = create_config_from_source( diff --git a/beta_spectrum/components/phase_space.py b/beta_spectrum/components/phase_space.py index 669cd42..bc7a837 100644 --- a/beta_spectrum/components/phase_space.py +++ b/beta_spectrum/components/phase_space.py @@ -30,6 +30,11 @@ class PhaseSpace(SpectrumComponent): """ super().__init__(logger=logger) self.tr_type = transition_type + if self._logger: + self._logger.debug( + "PhaseSpace: W0=%.5f, transition_type=%s, m_e=%.6f, m_nu=%.6f", + W0, transition_type, m_e, m_nu, + ) if self.tr_type not in ["A", "F1", "F1U", "F2", "F2U", "F3", "F3U", "F4"]: raise ValueError( f"Cannot calculate phase space for forbiddance '{self.tr_type}'.\nShould be [A, F1, F1U, F2, F2U, F3, F3U, F4].\n" @@ -47,17 +52,20 @@ class PhaseSpace(SpectrumComponent): phase_space = p_e * w_e * p_nu * w_nu if self.tr_type in ["A", "F1"]: - forbid_factor = 1 - + forbid_factor: float | np.ndarray = 1 elif self.tr_type in ["F1U", "F2"]: forbid_factor = p_nu**2 + p_e**2 - elif self.tr_type in ["F2U", "F3"]: forbid_factor = p_nu**4 + 3 / 10 * p_nu**2 * p_e**2 + p_e**4 - - elif self.tr_type in ["F3U", "F4"]: + else: # F3U, F4 forbid_factor = ( p_nu**6 + 7 * p_nu**4 * p_e**2 + 7 * p_nu**2 * p_e**4 + p_e**6 ) - return np.asarray(phase_space * forbid_factor, dtype=np.float64) + result = np.asarray(phase_space * forbid_factor, dtype=np.float64) + if self._logger: + self._logger.debug( + "PhaseSpace: output range=[%.6e, %.6e]", + result.min(), result.max(), + ) + return result diff --git a/beta_spectrum/cw_extractor.py b/beta_spectrum/cw_extractor.py index 3e048af..abdbd40 100644 --- a/beta_spectrum/cw_extractor.py +++ b/beta_spectrum/cw_extractor.py @@ -256,9 +256,9 @@ class CWExtractor: if self.config.use_radiative: from .components.radiative import RadiativeCorrection - W0 = T_to_W(self.config.endpoint_MeV) + W0_float = float(T_to_W(self.config.endpoint_MeV)) rad = RadiativeCorrection( - W0=W0, + W0=W0_float, Z=self.config.Z_parent, A=self.config.A_number, use_endpoint_resummation=True, @@ -291,9 +291,9 @@ class CWExtractor: Extracted C(W) values with uncertainties. """ if endpoint_keV is not None: - W0 = T_to_W(endpoint_keV / 1000.0) + W0_float = float(T_to_W(endpoint_keV / 1000.0)) else: - W0 = self._determine_endpoint() + W0_float = float(self._determine_endpoint()) # Compute theoretical factors (excluding C(W)) factors = self._theoretical_factors(self.energies_W) @@ -310,12 +310,12 @@ class CWExtractor: product *= factors[key] # Endpoint factor: (W0 - W)^2 - endpoint_factor = (W0 - self.energies_W) ** 2 + endpoint_factor = (W0_float - self.energies_W) ** 2 # Overall normalization if normalize_method == "endpoint": # Use flux at lowest energy where C(W) ≈ 1 for allowed decay - low_E_mask = self.energies_W < W0 - 0.02 + low_E_mask = self.energies_W < W0_float - 0.02 if np.any(low_E_mask): C0 = np.median( self.flux[low_E_mask] @@ -325,7 +325,7 @@ class CWExtractor: C0 = 1.0 else: # Fit normalization - C0 = self._fit_normalization(W0, product, endpoint_factor) + C0 = self._fit_normalization(W0_float, product, endpoint_factor) # Extract C(W) cw = self.flux / (C0 * product * endpoint_factor) @@ -347,8 +347,8 @@ class CWExtractor: cw_errors=cw_errors, flux=self.flux.copy(), flux_errors=self.flux_errors.copy(), - endpoint_W=W0, - endpoint_keV=W_to_T(W0) * 1000.0, + endpoint_W=W0_float, + endpoint_keV=float(W_to_T(W0_float)) * 1000.0, fit_result=None, ) @@ -585,7 +585,7 @@ class CWExtractor: p = momentum(W) from .components.fermi import FermiFunction - F = FermiFunction()(W, self.config.Z_parent) + F = FermiFunction(Z=self.config.Z_parent, A=self.config.A_number)(W) kurie = np.sqrt(flux / (p * W * F)) @@ -605,7 +605,7 @@ class CWExtractor: except np.linalg.LinAlgError: continue - return T_to_W(best_endpoint / 1000.0) + return float(T_to_W(best_endpoint / 1000.0)) def _fit_kurie_endpoint(self, cw_result: CWExtractionResult) -> FitResult: """Fit Kurie plot to determine endpoint energy.""" @@ -620,7 +620,7 @@ class CWExtractor: p = momentum(W) from .components.fermi import FermiFunction - F = FermiFunction()(W, self.config.Z_parent) + F = FermiFunction(Z=self.config.Z_parent, A=self.config.A_number)(W) kurie = np.sqrt(flux / (p * W * F)) E_keV = self.energies_keV[mask] @@ -639,7 +639,7 @@ class CWExtractor: ) return fitter.fit( - x0=[np.mean(kurie), -np.mean(kurie) / E_keV[-1]], + x0=[float(np.mean(kurie)), float(-np.mean(kurie) / E_keV[-1])], param_names=["a", "b"], ) diff --git a/beta_spectrum/fitter.py b/beta_spectrum/fitter.py index 804c3cf..43cfd6a 100644 --- a/beta_spectrum/fitter.py +++ b/beta_spectrum/fitter.py @@ -390,22 +390,22 @@ class CurveFitter: np.asarray(bounds[1], dtype=np.float64), ) - ls_kwargs = dict( - fun=self._residuals, - x0=x0_array, - method=self.config.method, - max_nfev=self.config.max_nfev, - x_scale=self.config.x_scale, - loss=self.config.loss, - f_scale=self.config.f_scale, - ftol=self.config.ftol, - xtol=self.config.xtol, - gtol=self.config.gtol, - ) + ls_kwargs: dict[str, object] = { + "fun": self._residuals, + "x0": x0_array, + "method": self.config.method, + "max_nfev": self.config.max_nfev, + "x_scale": self.config.x_scale, + "loss": self.config.loss, + "f_scale": self.config.f_scale, + "ftol": self.config.ftol, + "xtol": self.config.xtol, + "gtol": self.config.gtol, + } if bounds is not None: ls_kwargs["bounds"] = bounds_array - result = least_squares(**ls_kwargs) + result = least_squares(**ls_kwargs) # type: ignore[arg-type] # Compute covariance matrix from Jacobian # Cov = (J^T J)^(-1) * χ² / (n - p) diff --git a/beta_spectrum/logging_utils.py b/beta_spectrum/logging_utils.py index e4e6e8e..98b412d 100644 --- a/beta_spectrum/logging_utils.py +++ b/beta_spectrum/logging_utils.py @@ -137,9 +137,9 @@ def get_git_short_hash(length: int = 7) -> str: str Short git hash, or "unknown" if not in a git repo. """ - try: - import subprocess + import subprocess + try: result = subprocess.run( ["git", "rev-parse", f"--short={length}", "HEAD"], capture_output=True, diff --git a/beta_spectrum/nuclear_data.py b/beta_spectrum/nuclear_data.py index 36b0df9..5fb0a0e 100644 --- a/beta_spectrum/nuclear_data.py +++ b/beta_spectrum/nuclear_data.py @@ -309,6 +309,7 @@ def _resolve_decay_index( e = pe.ENSDF() pairs = e.ensdf_pairs(edata, mode) + assert pairs is not None, "paceENSDF returned None for ensdf_pairs" matching = [(k, v) for k, v in pairs.items() if str(k[0]) == parent_symbol] @@ -378,13 +379,31 @@ def get_decay_info_from_paceENSDF( jpi_data = e.get_parent_jpi(edata, nuclide, decay_idx, mode=mode) beta_data = e.get_beta_minus(edata, nuclide, decay_idx, units="best") - # Extract Q-value and half-life - q_value_keV = 0.0 + # Extract Q-value from ground state branch (state[7] = endpoint energy in keV) + # and half-life from halflife_data + endpoint_keV = 0.0 half_life_str = "unknown" - for k, v in halflife_data.items(): - q_value_keV = v[0] - half_life_str = str(v[2]) if len(v) > 2 else "unknown" - break + + if beta_data: + for k, v in beta_data.items(): + for state in v: + if state[0] == 0: # ground state branch + endpoint_keV = float(state[7]) + break + if endpoint_keV > 0: + break + + # No ground state branch — use first available branch + if endpoint_keV == 0.0: + for k, v in beta_data.items(): + if v and len(v) > 0: + endpoint_keV = float(v[0][7]) + break + + if halflife_data: + for k, v in halflife_data.items(): + half_life_str = str(v[2]) if len(v) > 2 else "unknown" + break # Extract parent spin/parity parent_J = jpi_data[0][0] if jpi_data else 0.0 @@ -398,10 +417,19 @@ def get_decay_info_from_paceENSDF( forbidden_code = "0A" # default transition_type = "A" # default + if beta_data is None: + beta_data = {} + for k, v in beta_data.items(): for state in v: - # state[15] contains forbiddenness code (e.g. '2F', '1UF') - if len(state) > 15 and state[15] is not None: + # Use ground state branch (level_index == 0) for forbiddenness + if state[0] == 0 and len(state) > 15 and state[15] is not None: + code = str(state[15]) + if code in FORBIDDENNESS_MAP: + forbidden_code = code + transition_type = FORBIDDENNESS_MAP[code] + # Fallback: use first branch's forbiddenness if no ground state + elif transition_type == "A" and len(state) > 15 and state[15] is not None: code = str(state[15]) if code in FORBIDDENNESS_MAP: forbidden_code = code @@ -420,8 +448,8 @@ def get_decay_info_from_paceENSDF( Z_parent=Z_parent, Z_daughter=Z_daughter, A_number=A_number, - endpoint_keV=q_value_keV, - endpoint_MeV=q_value_keV / 1000.0, + endpoint_keV=endpoint_keV, + endpoint_MeV=endpoint_keV / 1000.0, transition_type=transition_type, forbiddenness_code=forbidden_code, parent_J=parent_J, diff --git a/beta_spectrum/spectrum.py b/beta_spectrum/spectrum.py index 22adb19..3b8c755 100644 --- a/beta_spectrum/spectrum.py +++ b/beta_spectrum/spectrum.py @@ -169,7 +169,13 @@ class BetaSpectrum: if config.use_radiative: components.append( - RadiativeCorrection(W0=W0, use_endpoint_resummation=True, logger=logger) + RadiativeCorrection( + W0=W0, + Z=config.Z_daughter, + A=config.A_number, + use_endpoint_resummation=True, + logger=logger, + ) ) return cls(components, logger=logger) From d6328d4b243c30e1786a180006f4dc53ebed2fbb Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 5 May 2026 13:33:46 +0300 Subject: [PATCH 4/6] Enhance plot output with ID tracking and two display modes - Add commit hash (6 chars) and UTC timestamp to all plots via text boxes - Add nuclear data header (nuclide, endpoint, transition, corrections) to plots - Two display modes: - DEBUG (-vv): 4-panel debug view with all correction factors - Normal (default): single spectrum plot with nuclear data header - CLI: --plot now passes show_components=(args.verbose >= 2) --- beta_spectrum/cli.py | 5 +- beta_spectrum/spectrum.py | 134 +++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index 928b72b..d566318 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -265,7 +265,10 @@ def _run(args: argparse.Namespace) -> None: # Generate plot if args.plot: analyzer = BetaSpectrumAnalyzer(spectrum, config, logger=logger) - analyzer.plot_analysis(save_path=args.plot) + analyzer.plot_analysis( + save_path=args.plot, + show_components=(args.verbose >= 2), + ) logger.info("Analysis plot saved to %s", args.plot) logger.info("Done.") diff --git a/beta_spectrum/spectrum.py b/beta_spectrum/spectrum.py index 3b8c755..091a415 100644 --- a/beta_spectrum/spectrum.py +++ b/beta_spectrum/spectrum.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from datetime import datetime, timezone import numpy as np import matplotlib.pyplot as plt @@ -412,13 +413,142 @@ class BetaSpectrumAnalyzer: return convolved - def plot_analysis(self, save_path: Optional[str] = None) -> None: + def plot_analysis( + self, + save_path: Optional[str] = None, + show_components: bool = True, + ) -> None: """ - Create visualization of the spectrum and all correction. + Create visualization of the spectrum and all correction factors. + + Parameters + ---------- + save_path : str, optional + Path to save the figure. + show_components : bool + If True, show full debug view (4-panel with all corrections). + If False, show only the spectrum plot with nuclear data header. """ total = self.total_spectrum(normalize=True) components = self.components + commit = get_git_short_hash(6) + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + if show_components: + self._plot_debug_view( + total, components, save_path, commit, timestamp + ) + else: + self._plot_spectrum_only( + total, save_path, commit, timestamp + ) + + def _add_id_textbox( + self, ax: plt.Axes, commit: str, timestamp: str + ) -> None: + """Add ID text box with commit hash and timestamp to plot.""" + id_text = f"commit: {commit} | {timestamp}" + ax.text( + 0.01, + 0.01, + id_text, + transform=ax.transAxes, + fontsize=8, + color="gray", + verticalalignment="bottom", + horizontalalignment="left", + fontfamily="monospace", + ) + + def _add_nuclear_data_header(self, ax: plt.Axes) -> None: + """Add nuclear data information header to plot.""" + parent = self._element_symbol(self.config.Z_parent) + daughter = self._element_symbol(self.config.Z_daughter) + + enabled_corrections = [] + if self.config.use_phase_space: + enabled_corrections.append("phase_space") + if self.config.use_fermi: + enabled_corrections.append("fermi") + if self.config.use_screening: + enabled_corrections.append("screening") + if self.config.use_finite_size: + enabled_corrections.append("finite_size") + if self.config.use_charge_dist: + enabled_corrections.append("charge_dist") + if self.config.use_radiative: + enabled_corrections.append("radiative") + if self.config.use_exchange: + enabled_corrections.append("exchange") + + header_lines = [ + f"Nuclide: {parent}{self.config.A_number} -> {daughter}{self.config.A_number}", + f"Endpoint: {self.config.endpoint_MeV * 1000:.1f} keV", + f"Transition: {self.config.transition_type}", + f"Corrections: {', '.join(enabled_corrections)}", + ] + + if self.config.use_detector_response: + header_lines.append( + f"Detector: {self.config.detector_model} " + f"(σ={self.config.detector_sigma_a_keV} keV)" + ) + + header_text = "\n".join(header_lines) + ax.text( + 0.99, + 0.99, + header_text, + transform=ax.transAxes, + fontsize=8, + verticalalignment="top", + horizontalalignment="right", + bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8, edgecolor="none"), + ) + + def _plot_spectrum_only( + self, + total: np.ndarray, + save_path: Optional[str], + commit: str, + timestamp: str, + ) -> None: + """Plot only the total spectrum with nuclear data header.""" + fig, ax = plt.subplots(figsize=(10, 6)) + + ax.plot( + self.energies_MeV, total, "b-", lw=2, label="Normalized spectrum" + ) + ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=11) + ax.set_ylabel("Normalized Counts", fontsize=11) + ax.set_title( + f"Beta-decay: {self.config.Z_parent} -> {self.config.Z_daughter}, A={self.config.A_number}", + fontsize=13, + ) + ax.set_yscale("log") + ax.grid(True, alpha=0.3) + ax.legend(loc="best", fontsize=10) + + self._add_nuclear_data_header(ax) + self._add_id_textbox(ax, commit, timestamp) + + plt.tight_layout() + + if save_path: + fig.savefig(save_path, dpi=150, bbox_inches="tight") + print(f"Figure saved to {save_path}") + + plt.show() + + def _plot_debug_view( + self, + total: np.ndarray, + components: Dict[str, np.ndarray], + save_path: Optional[str], + commit: str, + timestamp: str, + ) -> None: + """Full debug view with 4-panel spectrum analysis.""" fig = plt.figure(figsize=(14, 10)) # 1. Main spectrum plot (top-left) From 5ac004352b27216b9f78c4761e0497085b894c4e Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 5 May 2026 14:01:28 +0300 Subject: [PATCH 5/6] Fix mypy type errors and black formatting - spectrum.py: Use Axes from TYPE_CHECKING instead of plt.Axes - fitter.py: Remove unused type: ignore comment - phase_space.py: Black formatting --- beta_spectrum/components/phase_space.py | 8 ++++++-- beta_spectrum/fitter.py | 2 +- beta_spectrum/spectrum.py | 26 ++++++++++++------------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/beta_spectrum/components/phase_space.py b/beta_spectrum/components/phase_space.py index bc7a837..92f334d 100644 --- a/beta_spectrum/components/phase_space.py +++ b/beta_spectrum/components/phase_space.py @@ -33,7 +33,10 @@ class PhaseSpace(SpectrumComponent): if self._logger: self._logger.debug( "PhaseSpace: W0=%.5f, transition_type=%s, m_e=%.6f, m_nu=%.6f", - W0, transition_type, m_e, m_nu, + W0, + transition_type, + m_e, + m_nu, ) if self.tr_type not in ["A", "F1", "F1U", "F2", "F2U", "F3", "F3U", "F4"]: raise ValueError( @@ -66,6 +69,7 @@ class PhaseSpace(SpectrumComponent): if self._logger: self._logger.debug( "PhaseSpace: output range=[%.6e, %.6e]", - result.min(), result.max(), + result.min(), + result.max(), ) return result diff --git a/beta_spectrum/fitter.py b/beta_spectrum/fitter.py index 43cfd6a..c7bcb6f 100644 --- a/beta_spectrum/fitter.py +++ b/beta_spectrum/fitter.py @@ -405,7 +405,7 @@ class CurveFitter: if bounds is not None: ls_kwargs["bounds"] = bounds_array - result = least_squares(**ls_kwargs) # type: ignore[arg-type] + result = least_squares(**ls_kwargs) # Compute covariance matrix from Jacobian # Cov = (J^T J)^(-1) * χ² / (n - p) diff --git a/beta_spectrum/spectrum.py b/beta_spectrum/spectrum.py index 091a415..fdb4bc9 100644 --- a/beta_spectrum/spectrum.py +++ b/beta_spectrum/spectrum.py @@ -3,9 +3,13 @@ from __future__ import annotations import logging from datetime import datetime, timezone +from typing import TYPE_CHECKING import numpy as np import matplotlib.pyplot as plt + +if TYPE_CHECKING: + from matplotlib.axes import Axes from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple from dataclasses import dataclass @@ -435,17 +439,11 @@ class BetaSpectrumAnalyzer: timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") if show_components: - self._plot_debug_view( - total, components, save_path, commit, timestamp - ) + self._plot_debug_view(total, components, save_path, commit, timestamp) else: - self._plot_spectrum_only( - total, save_path, commit, timestamp - ) + self._plot_spectrum_only(total, save_path, commit, timestamp) - def _add_id_textbox( - self, ax: plt.Axes, commit: str, timestamp: str - ) -> None: + def _add_id_textbox(self, ax: "Axes", commit: str, timestamp: str) -> None: """Add ID text box with commit hash and timestamp to plot.""" id_text = f"commit: {commit} | {timestamp}" ax.text( @@ -460,7 +458,7 @@ class BetaSpectrumAnalyzer: fontfamily="monospace", ) - def _add_nuclear_data_header(self, ax: plt.Axes) -> None: + def _add_nuclear_data_header(self, ax: "Axes") -> None: """Add nuclear data information header to plot.""" parent = self._element_symbol(self.config.Z_parent) daughter = self._element_symbol(self.config.Z_daughter) @@ -503,7 +501,9 @@ class BetaSpectrumAnalyzer: fontsize=8, verticalalignment="top", horizontalalignment="right", - bbox=dict(boxstyle="round,pad=0.3", facecolor="white", alpha=0.8, edgecolor="none"), + bbox=dict( + boxstyle="round,pad=0.3", facecolor="white", alpha=0.8, edgecolor="none" + ), ) def _plot_spectrum_only( @@ -516,9 +516,7 @@ class BetaSpectrumAnalyzer: """Plot only the total spectrum with nuclear data header.""" fig, ax = plt.subplots(figsize=(10, 6)) - ax.plot( - self.energies_MeV, total, "b-", lw=2, label="Normalized spectrum" - ) + ax.plot(self.energies_MeV, total, "b-", lw=2, label="Normalized spectrum") ax.set_xlabel(r"Electron kinetic energy $E$ [MeV]", fontsize=11) ax.set_ylabel("Normalized Counts", fontsize=11) ax.set_title( From 4a3d31de83fc4b07a28aa600f90714b74e200bbc Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 5 May 2026 14:35:11 +0300 Subject: [PATCH 6/6] Update docs and TODOs for CLI & plot improvements - README.md: Add features for plot modes, traceability, debug workflow - README.md: Update BetaSpectrumAnalyzer docs with show_components param - README.md: Update Quick Start with both plot modes - README.md: Add output/ to project structure - TODO.md: Mark B8.4 plot output as completed - TODO.md: Update Current Status with B8.4 --- README.md | 17 +++++++++++++---- TODO.md | 5 ++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 61f5712..e796cf3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,11 @@ A modular Python package for calculating high-precision **beta decay energy spec - Selective inclusion of higher-order corrections via `SpectrumConfig` toggles - Clean, composable component architecture - Built-in visualization and CSV export via `BetaSpectrumAnalyzer` + - Normal mode: single spectrum plot with nuclear data header + - Debug mode (`-vv`): 4-panel view with all correction factors + - All plots include commit hash and UTC timestamp for traceability - Comprehensive scientific reference in [`docs/`](docs/) +- Debug verification workflow: `bs_pnpi -vv` validates all parameters end-to-end ______________________________________________________________________ @@ -74,6 +78,7 @@ beta_shape_pnpi/ └── data/ ├── exchange_coeff.csv # Tabulated coefficients for X(Z,W), Z=2..120 └── custom_input_example.json # Sample JSON input file +├── output/ # Test run outputs (gitignored except .gitkeep) ``` ______________________________________________________________________ @@ -104,8 +109,11 @@ Defined in `beta_spectrum/spectrum.py`. Orchestrates the calculation: Also in `spectrum.py`. Provides debugging and visualization tools: - `total_spectrum(normalize=True/False)` — computed spectrum array -- `plot_analysis(save_path=None)` — 4-panel matplotlib figure (spectrum, components, cumulative effect, deviations from unity) -- `export_to_csv(filename)` — export via pandas DataFrame +- `plot_analysis(save_path=None, show_components=True)` — plot with two modes: + - `show_components=True` (debug mode): 4-panel figure (spectrum, components, cumulative effect, deviations) + - `show_components=False` (normal mode): single spectrum plot with nuclear data header + - All plots include commit hash and UTC timestamp for traceability +- `export_to_csv(filename)` — export via pandas DataFrame with YAML-style metadata header - `get_data()` — return all numerical data for custom analysis ______________________________________________________________________ @@ -128,8 +136,9 @@ W, E_MeV = spectrum.get_energy_grid(config) values = spectrum(W) analyzer = BetaSpectrumAnalyzer(spectrum, config) -analyzer.plot_analysis() # Show/ save 4-panel figure -analyzer.export_to_csv("output.csv") # Export data to CSV +analyzer.plot_analysis("analysis.png") # Normal mode (single spectrum plot) +analyzer.plot_analysis("debug.png", show_components=True) # Debug mode (4-panel) +analyzer.export_to_csv("spectrum.csv") # Export data to CSV ``` ______________________________________________________________________ diff --git a/TODO.md b/TODO.md index c65bf0f..2716513 100644 --- a/TODO.md +++ b/TODO.md @@ -180,6 +180,9 @@ ______________________________________________________________________ - [x] Add `-q/--quiet` flag to suppress terminal output - [x] Add `./output/` directory for debug verification artifacts (gitignored except `.gitkeep`) - [x] Add debug verification step to development workflow: `bs_pnpi -vv` with parameter consistency checks across all components, documented in DEVELOPMENT.md §4.5 +- [x] Add plot output with ID tracking (commit hash + UTC timestamp) and two display modes: + - Normal mode (default): single spectrum plot with nuclear data header + - Debug mode (-vv): 4-panel view with all correction factors and deviations ______________________________________________________________________ @@ -207,6 +210,6 @@ ______________________________________________________________________ **Version:** 0.3.0\ **Implemented:** Phase space, Fermi function, finite size, screening, exchange, radiative corrections (with delta_cut resummation). Detector response module with analytical models (Gaussian, Gaussian+tail, Tikhonov), convolution API, declarative config integration. χ² curve fitting framework (CurveFitter) with confidence intervals, profile likelihood, and correlation analysis. C(W) shape factor extraction pipeline (CWExtractor) with Kurie plot analysis, parametrized fitting, and g_V/g_A extraction. CLI interface (`bs_pnpi`) with paceENSDF integration, structured logging (-v/-vv/-q), --dry-run, --version, --log-file. CSV metadata headers. JSON input with full detector param support. Comprehensive test suite (195 tests). Notebook quality control with nbmake and auto-save plot hooks. -**Completed:** A2 — detector response function and convolution routines. A4 — fitter routine and C(W) extraction pipeline. B2 — input flexibility (JSON input, ENSDF auto-population). B8 — CLI & output improvements (logging, CSV headers, dry-run, version). B8.2 — CLI refinement: removed --mode, --transition-type, --decay-index flags; clarified units (MeV everywhere); enhanced logging (INFO shows components, DEBUG shows internals); CSV headers use element notation (Tc99 -> Ru99). B8.3 — debug verification framework: `./output/` directory for artifacts, development workflow step 4.5 with `-vv` parameter consistency checks across all components. +**Completed:** A2 — detector response function and convolution routines. A4 — fitter routine and C(W) extraction pipeline. B2 — input flexibility (JSON input, ENSDF auto-population). B8 — CLI & output improvements (logging, CSV headers, dry-run, version). B8.2 — CLI refinement: removed --mode, --transition-type, --decay-index flags; clarified units (MeV everywhere); enhanced logging (INFO shows components, DEBUG shows internals); CSV headers use element notation (Tc99 -> Ru99). B8.3 — debug verification framework: `./output/` directory for artifacts, development workflow step 4.5 with `-vv` parameter consistency checks across all components. B8.4 — plot output with ID tracking (commit hash + UTC timestamp) and two display modes (normal spectrum plot with nuclear data header; debug 4-panel view with all corrections). **Next immediate step:** A3 — data processing pipeline for experimental spectra (background subtraction, energy calibration, dead-time correction, pulse pile-up correction).