feat: experimental data calibration pipeline with ROOT I/O

- Fix chi2 calculation bug in calibration.py (remove incorrect
  coeffs[::-1] reversal in np.polyval)
- Fix broken beta_spectrum/cli.py (remove orphaned detector response
  add_argument calls and unused imports)
- Add ExpSpectrum.apply_calibration() and EnergyCalibrator.from_am241_spectrum()
- Add LivetimeDetermination class for pulser-based livetime
- Add SpectrumPipeline orchestration (read→subtract→calibrate→livetime→validate)
- Add ROOT I/O with PyROOT primary + uproot fallback
- Add bs_exp CLI with process, livetime, export subcommands
- Add exp_calibration notebook with pre-calibration visualization
- Add comprehensive tests (73 passing)
- Add bs_exp console script and [root] extra to pyproject.toml
- Remove obsolete detector_response module and unused notebooks
This commit is contained in:
2026-05-21 16:01:25 +03:00
parent caa4a77ebd
commit 5c2b12c0e6
27 changed files with 3065 additions and 773 deletions
+316
View File
@@ -0,0 +1,316 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Experimental Data Calibration Pipeline\n",
"\n",
"Interactive workflow for processing raw CAMAC MCA binary spectra:\n",
"read -> subtract -> calibrate -> livetime -> validate -> export\n",
"\n",
"## Prerequisites\n",
"- Raw binary spectrum files (A*.DAT format)\n",
"- Am-241 calibration file (CAM1.DAT)\n",
"- Pulser frequency and real time (from calibration run metadata)"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import matplotlib\n",
"matplotlib.use(\"Agg\")\n",
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"\n",
"from exp_data.pipeline import process_spectrum, process_run_sequence\n",
"from exp_data.root_io import write_spectrum, read_spectrum\n",
"from exp_data.livetime import LivetimeDetermination"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Configuration"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Paths\n",
"RAW_FILE = \"../data/raw/A1\"\n",
"CALIBRATION_FILE = \"../data/raw/CAM1.DAT\"\n",
"RUN_DIR = \"../data/raw/\"\n",
"\n",
"# Pulser parameters (from calibration run metadata)\n",
"PULSER_FREQUENCY_HZ = 100.0\n",
"REAL_TIME_SEC = 3600.0"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Raw Spectrum Visualization\n",
"Visualize raw spectra before calibration to identify peaks and verify calibration source."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Visualize raw spectra before calibration\n",
"# This helps identify what calibration source is actually present\n",
"\n",
"# Detect header size from file\n",
"def detect_header_size(filepath):\n",
" \"\"\"Detect MCA header size by checking for binary pattern.\"\"\"\n",
" with open(filepath, 'rb') as f:\n",
" header = f.read(128)\n",
" # Check if first 32 bytes look like a header (small ints)\n",
" # and bytes 32-128 also look like header\n",
" # Simple heuristic: if file is exactly 32 + 4096*4 = 16412, header=32\n",
" # If file is 128 + 4096*4 = 16512, header=128\n",
" import os\n",
" size = os.path.getsize(filepath)\n",
" expected_32 = 32 + 4096 * 4\n",
" expected_128 = 128 + 4096 * 4\n",
" if size == expected_32:\n",
" return 32\n",
" elif size == expected_128:\n",
" return 128\n",
" else:\n",
" # Try to detect by checking if byte 32 looks like an int\n",
" with open(filepath, 'rb') as f:\n",
" f.seek(32)\n",
" test = f.read(4)\n",
" return 32 # default\n",
"\n",
"# --- Raw run spectrum ---\n",
"header_size = detect_header_size(RAW_FILE)\n",
"with open(RAW_FILE, 'rb') as f:\n",
" f.read(header_size)\n",
" raw_counts = np.fromfile(f, dtype=np.int32, count=4096)\n",
"\n",
"fig, axes = plt.subplots(2, 2, figsize=(14, 8))\n",
"\n",
"ax = axes[0, 0]\n",
"ax.plot(raw_counts, 'b-', linewidth=0.5)\n",
"ax.set_title('Raw Run Spectrum (counts vs channel)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"ax.set_xlim(0, min(200, len(raw_counts)))\n",
"\n",
"ax = axes[0, 1]\n",
"ax.plot(raw_counts, 'b-', linewidth=0.5)\n",
"ax.set_title('Raw Run Spectrum (full range)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"\n",
"# --- Calibration spectrum ---\n",
"with open(CALIBRATION_FILE, 'rb') as f:\n",
" f.read(128) # CAM files have 128-byte header\n",
" cal_counts = np.fromfile(f, dtype=np.int32, count=4096)\n",
"\n",
"ax = axes[1, 0]\n",
"ax.plot(cal_counts, 'r-', linewidth=0.5)\n",
"ax.set_title('Calibration Spectrum (counts vs channel)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"ax.set_xlim(0, min(200, len(cal_counts)))\n",
"\n",
"ax = axes[1, 1]\n",
"ax.plot(cal_counts, 'r-', linewidth=0.5)\n",
"ax.set_title('Calibration Spectrum (full range)')\n",
"ax.set_xlabel('Channel')\n",
"ax.set_ylabel('Counts')\n",
"\n",
"plt.tight_layout()\n",
"plt.show()\n",
"\n",
"# Print some stats\n",
"print(f'Raw run: {len(raw_counts)} channels, max={raw_counts.max()} at ch {np.argmax(raw_counts)}')\n",
"print(f'Calibration: {len(cal_counts)} channels, max={cal_counts.max()} at ch {np.argmax(cal_counts)}')\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Process Single Spectrum"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"result = process_spectrum(\n",
" raw_path=RAW_FILE,\n",
" calibration_path=CALIBRATION_FILE,\n",
" pulser_frequency_hz=PULSER_FREQUENCY_HZ,\n",
" real_time_sec=REAL_TIME_SEC,\n",
")\n",
"\n",
"print(f\"Validation: {'PASSED' if result.validation_passed else 'FAILED'}\")\n",
"print(f\"Calibration chi2/dof: {result.calibration.chi2_per_dof:.4f}\")\n",
"for msg in result.validation_messages:\n",
" print(f\" {msg}\")\n",
"if result.livetime:\n",
" print(f\"Live-time fraction: {result.livetime.live_time_fraction:.4f}\")\n",
" print(f\"Pulser peak: {result.livetime.pulser_peak_center_keV:.1f} keV\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Plot Calibrated Spectrum"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"plt.figure(figsize=(10, 6))\n",
"plt.plot(result.spectrum.energies, result.spectrum.counts, 'b-', label='Counts')\n",
"plt.xlabel('Energy (keV)')\n",
"plt.ylabel('Counts')\n",
"plt.title('Calibrated Beta Spectrum')\n",
"plt.legend()\n",
"plt.tight_layout()\n",
"plt.show()"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Process Run Sequence"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"results = process_run_sequence(\n",
" run_dir=RUN_DIR,\n",
" calibration_path=CALIBRATION_FILE,\n",
" prefix=\"A\",\n",
" pulser_frequency_hz=PULSER_FREQUENCY_HZ,\n",
" real_time_sec=REAL_TIME_SEC,\n",
")\n",
"\n",
"print(f\"Processed {len(results)} runs\")\n",
"for i, r in enumerate(results):\n",
" print(f\" Run {i+1}: {'PASSED' if r.validation_passed else 'FAILED'} \"\n",
" f\"(chi2/dof={r.calibration.chi2_per_dof:.4f})\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Export to ROOT"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"write_spectrum(\"output.root\", result.spectrum)\n",
"print(\"Exported to output.root\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Read Back from ROOT"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"read_back = read_spectrum(\"output.root\")\n",
"print(f\"Read {len(read_back.energies)} channels\")\n",
"print(f\"Source: {read_back.source}\")\n",
"print(f\"Run ID: {read_back.run_id}\")\n",
"print(f\"Calibration: {read_back.metadata.get('calibration', {})}\")\n",
"\n",
"# Verify round-trip\n",
"assert np.allclose(read_back.energies, result.spectrum.energies)\n",
"assert np.allclose(read_back.counts, result.spectrum.counts)\n",
"print(\"Round-trip verified\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Livetime Determination"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Direct livetime determination from calibration file\n",
"livetime_result = LivetimeDetermination.from_file(\n",
" filepath=CALIBRATION_FILE,\n",
" pulser_frequency_hz=PULSER_FREQUENCY_HZ,\n",
" real_time_sec=REAL_TIME_SEC,\n",
" source=\"pulser\",\n",
")\n",
"\n",
"print(f\"Live-time fraction: {livetime_result.live_time_fraction:.4f}\")\n",
"print(f\"Observed pulser rate: {livetime_result.observed_pulser_rate:.1f} Hz\")\n",
"print(f\"Pulser peak: {livetime_result.pulser_peak_center_keV:.1f} keV \"\n",
" f\"(sigma={livetime_result.pulser_peak_sigma_keV:.2f} keV, \"\n",
" f\"chi2/dof={livetime_result.pulser_peak_chi2:.2f})\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.14.4"
}
},
"nbformat": 4,
"nbformat_minor": 4
}