From 8b0ced4b65634d429f38c2f5cf9db84ffc6eac29 Mon Sep 17 00:00:00 2001 From: Evgeniy Unzhakov Date: Tue, 26 May 2026 14:57:35 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20resolve=204=20fatal=20bugs=20=E2=80=94?= =?UTF-8?q?=20json=5Fto=5Fconfig,=20create=5Fconfig=5Ffrom=5Fsource,=20Am-?= =?UTF-8?q?241=20constant,=20CLI=20positional=20args?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- beta_spectrum/cli.py | 77 ++++++++++++++++++++---------- beta_spectrum/nuclear_data.py | 9 ++-- exp_data/calibration.py | 7 ++- exp_data/raw_reader.py | 8 ++-- scripts/analyze_raw.py | 14 ++++-- tests/quality/test_nuclear_data.py | 30 +++++++++--- 6 files changed, 99 insertions(+), 46 deletions(-) diff --git a/beta_spectrum/cli.py b/beta_spectrum/cli.py index 7652680..d47e9be 100644 --- a/beta_spectrum/cli.py +++ b/beta_spectrum/cli.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse import sys +from pathlib import Path from beta_spectrum.logging_utils import LoggingConfig, setup_logging from beta_spectrum.spectrum import SpectrumConfig @@ -52,8 +53,18 @@ def _build_parser() -> argparse.ArgumentParser: version=f"%(prog)s {__import__('beta_spectrum').__version__}", ) - # Source selection (mutually exclusive) - source_group = parser.add_mutually_exclusive_group(required=True) + # Source selection (positional or flag) + parser.add_argument( + "source", + nargs="?", + default=None, + help="Nuclide symbol (e.g. 'Tc99') or JSON file path. " + "Can also use --nuclide or --input . " + "Auto-detected: .json extension → JSON input, otherwise → paceENSDF.", + ) + + # Source selection flags (backward compatible, mutually exclusive) + source_group = parser.add_mutually_exclusive_group(required=False) source_group.add_argument( "--nuclide", type=str, @@ -171,12 +182,36 @@ def _run(args: argparse.Namespace) -> None: logger.info("bs_pnpi starting...") logger.debug("Verbosity: %s | Log file: %s", log_level, args.log_file or "none") + # Resolve source: positional arg takes precedence, then flags + source_value = args.source + if source_value is None and args.nuclide: + source_value = args.nuclide + elif source_value is None and args.input: + source_value = args.input + elif source_value is not None: + # Auto-detect from positional argument + if source_value.endswith(".json") or Path(source_value).exists(): + source_value = source_value + # else: keep as-is (nuclide symbol) + + if source_value is None: + logger.error( + "Required: specify nuclide or JSON file " + "(e.g., 'bs_pnpi Tc99' or 'bs_pnpi --nuclide Tc99')" + ) + sys.exit(1) + + # Determine source type + if source_value.endswith(".json"): + source_type = "json" + else: + source_type = "paceENSDF" + # Build config - source_type: str config: SpectrumConfig - if args.nuclide: - logger.info("Source: paceENSDF nuclide=%s", args.nuclide) - decay_info = get_decay_info_from_paceENSDF(args.nuclide, "beta_minus") + if source_type == "paceENSDF": + logger.info("Source: paceENSDF nuclide=%s", source_value) + decay_info = get_decay_info_from_paceENSDF(source_value, "beta_minus") # Normalize branch intensities to sum to 1.0 if decay_info.branches: @@ -208,24 +243,21 @@ def _run(args: argparse.Namespace) -> None: else: config = create_config_from_source( "paceENSDF", - nuclide=args.nuclide, + nuclide=source_value, e_step_MeV=args.e_step, + intensity_cutoff=args.intensity_cutoff, ) else: config = create_config_from_source( "paceENSDF", - nuclide=args.nuclide, + nuclide=source_value, e_step_MeV=args.e_step, + intensity_cutoff=args.intensity_cutoff, ) - source_type = "paceENSDF" - elif args.input: - logger.info("Source: JSON file=%s", args.input) - data = load_json_input(args.input) - config = json_to_config(data) - source_type = "json" else: - logger.error("No source specified (use --nuclide or --input)") - sys.exit(1) + logger.info("Source: JSON file=%s", source_value) + data = load_json_input(source_value) + config = json_to_config(data) # Dry run: display resolved config and exit if args.dry_run: @@ -307,24 +339,19 @@ def _print_dry_run_output(config, source_type: str) -> None: print(f"\nMulti-branch mode: {len(config.branches)} branches") if config.intensity_cutoff > 0: print( - f"Intensity cutoff: {config.intensity_cutoff:.4f} ({config.intensity_cutoff*100:.1f}%)" + f"Intensity cutoff: {config.intensity_cutoff:.4f} ({config.intensity_cutoff * 100:.1f}%)" ) for i, branch in enumerate(config.branches): print( - f" Branch {i+1}: E₀={branch.endpoint_MeV*1000:.1f} keV, " + f" Branch {i + 1}: E₀={branch.endpoint_MeV * 1000:.1f} keV, " f"transition={branch.transition_type}, " f"intensity={branch.intensity:.4f}" ) else: print("Mode: single-branch") - if config.use_detector_response: - print( - f"Detector: {config.detector_model} (sigma={config.detector_sigma_a_keV} keV, " - f"tail={config.detector_tail_fraction})" - ) - else: - print("Detector: disabled") + # Detector response is a Phase 4 feature — not yet implemented + print("Detector: not configured (Phase 4)") print("========================================") diff --git a/beta_spectrum/nuclear_data.py b/beta_spectrum/nuclear_data.py index 6bf8ead..7e1a19a 100644 --- a/beta_spectrum/nuclear_data.py +++ b/beta_spectrum/nuclear_data.py @@ -272,7 +272,7 @@ def _get_decay_mode_symbol( } if decay_type not in mapping: raise ValueError( - f"Unknown decay type '{decay_type}'. " f"Supported: {list(mapping.keys())}" + f"Unknown decay type '{decay_type}'. Supported: {list(mapping.keys())}" ) return mapping[decay_type] @@ -638,9 +638,6 @@ def json_to_config(data: Dict[str, Any]) -> SpectrumConfig: endpoint_MeV=float(data["endpoint_MeV"]), transition_type=str(data.get("transition_type", "A")), e_step_MeV=float(data.get("e_step_MeV", 0.001)), - detector_model=str(data.get("detector_model", "gaussian")), - detector_channel_energy_range=tuple( - ), ) @@ -685,8 +682,12 @@ def create_config_from_source( decay_type = kwargs.get("decay_type", "beta_minus") decay_index = kwargs.get("decay_index", None) e_step = kwargs.get("e_step_MeV", 0.001) + intensity_cutoff = kwargs.get("intensity_cutoff", 0.0) info = get_decay_info_from_paceENSDF(nuclide, decay_type, decay_index) return decay_info_to_config( + info, + e_step_MeV=e_step, + intensity_cutoff=intensity_cutoff, ) elif source == "json": if not json_path: diff --git a/exp_data/calibration.py b/exp_data/calibration.py index a726f97..014e37c 100644 --- a/exp_data/calibration.py +++ b/exp_data/calibration.py @@ -312,7 +312,7 @@ class EnergyCalibrator: filepath : str or Path Path to the Am-241 binary calibration file (CAM1.DAT or CAM2.DAT). known_energies : list of float, optional - Known Am-241 line energies in keV. Defaults to [13.8, 17.8, 26.3]. + Known Am-241 line energies in keV. Defaults to [13.9, 17.8, 26.3, 59.54]. n_channels : int Number of channels (default 4096). @@ -352,8 +352,11 @@ class EnergyCalibrator: peak_counts = counts_search[peaks] # Known Am-241 gamma lines (keV) + # Am-241 decays to Np-237; Np K X-rays at 13.9 keV (Kα), 17.8 keV (Kβ), + # 20.8 keV (L-series); Np gamma at 26.3 keV; Np/Am gamma at 59.54 keV. + # Source: LNHb Table de Radionucléides, IAEA nuclear data. if known_energies is None: - known_energies = [13.0, 17.8, 26.3, 59.54] + known_energies = [13.9, 17.8, 26.3, 59.54] # Match peaks to known energies using a simpler, more robust # approach: find the two strongest peaks that span the largest diff --git a/exp_data/raw_reader.py b/exp_data/raw_reader.py index fe43fe3..9acd249 100644 --- a/exp_data/raw_reader.py +++ b/exp_data/raw_reader.py @@ -151,7 +151,7 @@ def calibrate_from_am241( Path to the Am-241 binary file (CAM1.DAT or CAM2.DAT). known_peaks Mapping of energy_keV -> expected channel. If None, auto-detects - Am-241 peaks at 13.8, 17.8, 26.3 keV. + Am-241 peaks at 13.9, 17.8, 26.3 keV. n_channels Number of channels. @@ -175,7 +175,7 @@ def calibrate_from_am241( _peak_heights = props["peak_heights"] # noqa: F841 # Known Am-241 gamma lines (keV) - am241_lines = {13.8: None, 17.8: None, 26.3: None} + am241_lines = {13.9: None, 17.8: None, 26.3: None} if known_peaks: am241_lines = known_peaks else: @@ -220,7 +220,7 @@ def read_am241_calibration( """ filepath = Path(filepath) - # Am-241 known peaks (keV): 13.8, 17.8, 26.3, 59.54 + # Am-241 known peaks (keV): 13.9, 17.8, 26.3, 59.54 # The spectrum typically has a few prominent peaks with open(filepath, "rb") as f: _header = f.read(128) @@ -232,7 +232,7 @@ def read_am241_calibration( metadata = { "source": "Am241", "calibration_type": "energy", - "known_peaks_keV": [13.8, 17.8, 26.3, 59.54], + "known_peaks_keV": [13.9, 17.8, 26.3, 59.54], } return ExpSpectrum( diff --git a/scripts/analyze_raw.py b/scripts/analyze_raw.py index 93c78c4..212bc70 100644 --- a/scripts/analyze_raw.py +++ b/scripts/analyze_raw.py @@ -29,7 +29,7 @@ from exp_data.raw_reader import ( ) # Energy calibration from Am-241 source: E = offset + gain * channel -# Determined automatically from known Am-241 gamma lines (13.8, 17.8, 26.3 keV) +# Determined automatically from known Am-241 gamma lines (13.9, 17.8, 26.3 keV) CALIBRATION_FILE = "data/raw/CAM1.DAT" _calib_offset, _calib_gain = calibrate_from_am241(CALIBRATION_FILE) ENERGY_OFFSET = _calib_offset @@ -212,9 +212,9 @@ def main(): endpoints = np.array(endpoints) # Print summary - print(f"\n{'='*60}") + print(f"\n{'=' * 60}") print(f"Analysis of {len(run_ids)} individual Tc-99 runs") - print(f"{'='*60}") + print(f"{'=' * 60}") print(f"Run range: {run_ids.min()} - {run_ids.max()}") print(f"Total counts: {total_rates.min():.0f} - {total_rates.max():.0f}") print(f"Low-energy fraction: {low_fractions.min():.3f} - {low_fractions.max():.3f}") @@ -234,7 +234,9 @@ def main(): # Save results results_file = output_dir / "rate_analysis.csv" with open(results_file, "w") as f: - f.write("run,total_counts,low_energy_counts,high_energy_counts,low_fraction,endpoint_keV\n") + f.write( + "run,total_counts,low_energy_counts,high_energy_counts,low_fraction,endpoint_keV\n" + ) for i in range(len(run_ids)): f.write( f"{run_ids[i]},{total_rates[i]:.0f},{low_rates[i]:.0f}," @@ -265,7 +267,9 @@ def main(): # Endpoint axes[1, 0].plot(run_ids, endpoints, "r-", lw=1.5) axes[1, 0].axhline( - TC99_ENDPOINT, color="gray", ls="--", + TC99_ENDPOINT, + color="gray", + ls="--", label=f"Tc-99 Q ≈ {TC99_ENDPOINT} keV", ) axes[1, 0].set_xlabel("Run number") diff --git a/tests/quality/test_nuclear_data.py b/tests/quality/test_nuclear_data.py index 6c768bb..0c95aee 100644 --- a/tests/quality/test_nuclear_data.py +++ b/tests/quality/test_nuclear_data.py @@ -202,7 +202,8 @@ class TestDecayInfoToConfig: assert abs(config.endpoint_MeV - 0.294) < 1e-6 assert config.transition_type == "A" - def test_with_detector_response(self): + def test_with_e_step_and_cutoff(self): + """Verify e_step_MeV and intensity_cutoff kwargs are passed through.""" info = DecayInfo( parent_symbol="Co60", Z_parent=27, @@ -215,13 +216,30 @@ class TestDecayInfoToConfig: parent_J=0.0, parent_pi=1, half_life="y", - branches=[], + branches=[ + BranchInfo( + level_index=0, + level_energy_keV=0.0, + intensity=0.5, + log_ft=5.0, + transition_type="A", + ), + BranchInfo( + level_index=1, + level_energy_keV=10.0, + intensity=0.3, + log_ft=6.0, + transition_type="A", + ), + ], ) - config = decay_info_to_config( - info, use_detector_response=True, e_step_MeV=0.005 - ) - assert config.use_detector_response is True + config = decay_info_to_config(info, e_step_MeV=0.005, intensity_cutoff=0.4) assert config.e_step_MeV == 0.005 + assert config.intensity_cutoff == 0.4 + # Only branch with intensity >= 0.4 should be included + assert config.branches is not None + assert len(config.branches) == 1 + assert config.branches[0].intensity == 0.5 # ---------------------------------------------------------------------------