diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3cf37ae..4019cc1 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -67,6 +67,7 @@ All of the following MUST pass before merging to `main`: ```bash pytest # all tests pass +nbmake notebooks/ # execute and validate notebooks black --check . # code formatting ruff check . # linting mypy . # type checking (strict mode) @@ -74,6 +75,38 @@ mypy . # type checking (strict mode) These are configured in `pyproject.toml`. Run them individually to identify failures. +### Notebook Quality Control + +Notebooks in `notebooks/` are executed as pytest tests using `nbmake`. This ensures: + +- All code cells execute without errors +- Full error traces are available for debugging +- Plots are extracted for LLM-based visual analysis + +**Workflow:** + +1. Write notebooks with explicit figure saving: + ```python + fig.savefig('_plots/notebook_name_cell{N}.png', dpi=100, bbox_inches='tight') + from IPython.display import display, Image + display(Image(filename='_plots/notebook_name_cell{N}.png')) + ``` + +2. Execute and extract plots: + ```bash + python scripts/run_notebooks.py notebooks/ + ``` + +3. Send extracted plots to LLM for visual review: + ``` + @notebooks/_plots/notebook_name_cell4.png + ``` + +4. Run full quality gates: + ```bash + pytest notebooks/ --nbmake # or just `pytest` (includes notebooks/) + ``` + ## 5. Agent Instructions When working on this project, agents must: diff --git a/notebooks/conftest.py b/notebooks/conftest.py new file mode 100644 index 0000000..0d84d19 --- /dev/null +++ b/notebooks/conftest.py @@ -0,0 +1,75 @@ +# conftest.py — shared fixtures for notebooks +import matplotlib +matplotlib.use("Agg") # non-interactive backend for CI + +import matplotlib.pyplot as plt +import numpy as np +import os +import pytest + +# Auto-save figures directory +PLOTS_DIR = os.path.join(os.path.dirname(__file__), "_plots") +os.makedirs(PLOTS_DIR, exist_ok=True) +_plot_counter = 0 + + +def _save_figure_on_close(fig): + """Save figure to PNG before closing.""" + global _plot_counter + plot_path = os.path.join(PLOTS_DIR, f"notebook_{_plot_counter:03d}.png") + try: + fig.savefig(plot_path, dpi=100, bbox_inches="tight") + _plot_counter += 1 + except Exception: + pass + + +# Patch plt.close to auto-save before closing +_original_close = plt.close + + +def _patched_close(fig=None): + if fig is not None: + _save_figure_on_close(fig) + return _original_close(fig) + + +plt.close = _patched_close +plt.rcParams.update({"figure.autolayout": True}) + + +@pytest.fixture(autouse=True) +def close_figures(): + """Close all matplotlib figures after each notebook cell execution.""" + yield + plt.close("all") + + +@pytest.fixture() +def W_low(): + """Low-energy total energy: T ≈ 10 keV → W = 1 + 0.01/0.511 ≈ 1.02.""" + return np.array([1.02]) + + +@pytest.fixture() +def W_mid(): + """Mid-range energies (bulk of spectrum).""" + return np.linspace(1.1, 2.0, 5) + + +@pytest.fixture() +def W_high(): + """High-energy total energy near endpoint: T ≈ 3 MeV → W ≈ 7.""" + return np.array([6.8]) + + +@pytest.fixture() +def W_full(): + """Full spectrum grid from ~1 keV to just below endpoint.""" + return np.linspace(1.003, 5.8, 100) + + +@pytest.fixture() +def large_W(): + """Large array for performance/shape checks.""" + return np.linspace(1.002, 4.9, 1000) diff --git a/notebooks/smoke_test.ipynb b/notebooks/smoke_test.ipynb new file mode 100644 index 0000000..f71289b --- /dev/null +++ b/notebooks/smoke_test.ipynb @@ -0,0 +1,159 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Quick Smoke Test\n", + "\n", + "Verifies that the notebook execution pipeline works." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "imports", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-01T22:46:35.931027Z", + "iopub.status.busy": "2026-05-01T22:46:35.930620Z", + "iopub.status.idle": "2026-05-01T22:46:37.237362Z", + "shell.execute_reply": "2026-05-01T22:46:37.235705Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib\n", + "matplotlib.use('Agg')\n", + "import matplotlib.pyplot as plt\n", + "from IPython.display import display, Image\n", + "from beta_spectrum import BetaSpectrum, SpectrumConfig" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "spectrum-basic", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-01T22:46:37.242031Z", + "iopub.status.busy": "2026-05-01T22:46:37.241427Z", + "iopub.status.idle": "2026-05-01T22:46:37.247700Z", + "shell.execute_reply": "2026-05-01T22:46:37.246000Z" + } + }, + "outputs": [], + "source": [ + "# Build a basic 99Tc spectrum\n", + "config = SpectrumConfig(\n", + " Z_parent=43, Z_daughter=44, A_number=99,\n", + " endpoint_MeV=0.294, transition_type='F1',\n", + " use_screening=False, use_exchange=False,\n", + " use_finite_size=False,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "spectrum-compute", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-01T22:46:37.252211Z", + "iopub.status.busy": "2026-05-01T22:46:37.251845Z", + "iopub.status.idle": "2026-05-01T22:46:37.259124Z", + "shell.execute_reply": "2026-05-01T22:46:37.257471Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Spectrum: 293 points, peak at T=0.001 MeV\n" + ] + } + ], + "source": [ + "spectrum = BetaSpectrum.from_config(config)\n", + "W, kinetic_MeV = spectrum.get_energy_grid(config)\n", + "flux = spectrum(W)\n", + "assert len(W) == len(kinetic_MeV) == len(flux)\n", + "assert np.all(np.isfinite(flux))\n", + "print(f'Spectrum: {len(W)} points, peak at T={kinetic_MeV[np.argmax(flux)]:.3f} MeV')" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "plot-basic", + "metadata": { + "execution": { + "iopub.execute_input": "2026-05-01T22:46:37.263766Z", + "iopub.status.busy": "2026-05-01T22:46:37.263206Z", + "iopub.status.idle": "2026-05-01T22:46:37.497607Z", + "shell.execute_reply": "2026-05-01T22:46:37.496426Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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L599KPS2U6dOcfXqVYdep9tDj7eHukqVKsXOnTt54okn7jnkVqpUKRITE9m7dy/VqlW763UPMmx3W758+bh06VKS83/++ecDf5ZIdqc5TiJZxNy5c9m2bRuvv/56kjkyd7p+/TrDhg2jcOHCtG3b1qnPfv755zEMw77o5J1S2ttw8OBBDhw4QNWqVe3nWrVqxebNm1mxYkWS6y9dumRf7fz2U4H/DAO3e0T+WdO4ceOcqunXX39N9km0rVu38tdffzksLApm8Pviiy/sr+Pi4vjiiy8oVKgQwcHB9jadPHmSqVOnJvnc69ev27/v2WefxcXFhXfffTdJz9id7cmVK1eyIeheSpUqRUxMDLt27bKfO336NIsWLXqgzxER9TiJZErr16/n3XffpXHjxhQoUIAtW7bw1Vdf0bRpU/r27etwbatWrQgICKBChQrExsYybdo0jhw5wtKlSx0m+t5Lw4YN6dChA59//jmHDh2yD3tt2LCBhg0b8uqrr97z/lu3bvHNN98A5tDVsWPHmDx5MomJiQ4TzgcMGMD3339P8+bN6dy5M8HBwVy9epXff/+d7777jmPHjlGwYEG8vLyoUKECc+fOpWzZsuTPn59KlSpRqVIlHnvsMcaMGUN8fDxFihThp59+4ujRo061c+bMmcyaNYvnnnuO4OBg3N3d2bdvH9OmTcPT09M+FHpbQEAAo0eP5tixY5QtW5a5c+cSFRXFlClTyJEjBwAdOnRg3rx59OjRgzVr1lCnTh0SEhLYv38/8+bNY8WKFYSEhFC6dGneeustRo0aRb169WjZsiUeHh5s27aNgIAAIiIiAAgODmbSpEm89957lC5dGl9fX/tE87tp06YNgwYN4rnnnuO1117j2rVrTJo0ibJly953wryI/IOVj/SJSMr88ccfRuPGjY2CBQsaHh4eRvny5Y2IiAjj5s2bSa4dPXq0Ub58ecPT09PIly+f8fTTTxs7duy45+cnt3L4rVu3jI8++sgoX7684e7ubhQqVMh48sknkywp8E/JLUfg7e1tPPHEE8bPP/+c5PrLly8bQ4YMMUqXLm24u7sbBQsWNGrXrm18/PHH9kf8DcMwNm3aZAQHBxvu7u4OSxP873//M5577jkjb968ho+Pj/HCCy8Yp06dcmql8V27dhkDBgwwqlevbuTPn99wc3MzChcubLzwwgtGZGSk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5eXFtWvX7lqjiICCk0g2UqRIEb777jsaNmxI06ZN+fHHH+87GfyfYcZms5GYmAjAlStXCA4Ots+3uVOhQoWcrsvLy8vpa+8UGBhI6dKl7/r+/WoHc85QkSJFHK7z8PBweJ0rV64Hrq1FixZ4eHiwaNEi3N3diY+P51//+tddry9YsCAAFy9eTPLbBQUFERkZybRp0wgJCcFms9nb4Mzv361bN5544gn++OMP1qxZg6urKy+88EKSey5cuPBA/9xEsiMFJ5Fspnjx4qxbt84enpYvX57iJ+mqV6/O3Llz8fX1xdvbO9lr3N3dSUhIuOfnVKlShRkzZhAfH//AvU4pVaFCBTw8PDh+/Dj169d3+r5HHnmEHDlysG3bNooVKwZATEwMBw8e5LHHHrNf5+bmRqdOnfjqq69wd3enTZs29wyIpUqVwtvbm71791K2bNkk740dO5YGDRrg6urK+PHjAed+f4CGDRtSsmRJvvrqK9asWUObNm2SDYO7d++mQYMGTv8WItmRJoeLZEOBgYGsXbuWs2fP0qRJE2JjY1P0Oe3ataNgwYI888wzbNiwgaNHj7J27Vpee+01+2KKJUqUYNeuXRw4cIDz588THx+f5HNeffVVYmNjadOmDb/99huHDh1i5syZ9knod/PXX38RHR3tcNy4ccOp2vPkycObb75Jv379mDFjBocPHyYyMpJ///vfzJgx4573derUiQEDBrBmzRr27NlDt27dcHFxsfcE3da9e3dWr17N8uXLk0zE/icXFxfCw8PZuHFjsu+XLVuWNWvWsGDBAvvTe878/mD2tHXt2pVJkyaxefNmunXrluTzr127xvbt22ncuPE96xTJ7hScRLKpokWLsnbtWs6fP5/i8JQzZ07Wr19PsWLFaNmyJY8++ijdunXjxo0b9h6Ql156iXLlyhESEkKhQoX45ZdfknxOgQIFWL16NVeuXKF+/foEBwczderU+/Y+hYeHU7hwYYdj8eLFTtc/atQohg0bRkREBI8++ihNmzZl6dKllCxZ8p73ffLJJ4SFhdG8eXPCw8OpU6cOjz76aJL5SWXKlKF27dqUL1+e0NDQ+9bTvXt35syZYx9O/Kdy5cqxevVqvv32W9544w2nfv/bOnfuTExMDBUrVky2lv/+978UK1aMevXq3bdOkezMZhiGYXURIiKZ2dWrVylSpAhjx4516M0xDIMyZcrQq1cv+vfvf9/PMQyD0NBQ+vXrR9u2bdOy5CRq1arFa6+9xosvvpiu3yuS2WiOk4jIA9qxYwf79++nZs2axMTE8O677wLwzDPP2K85d+4cc+bMITo6+q5rN/2TzWZjypQp/P7772lS992cP3+eli1bpntYE8mM1OMkIvKAduzYQffu3Tlw4ADu7u4EBwfzySefULlyZfs1NpuNggUL8tlnn6kXRyQLUXASERERcZImh4uIiIg4ScFJRERExEkKTiIiIiJOUnASERERcZKCk4iIiIiTFJxEREREnKTgJCIiIuIkBScRERERJyk4iYiIiDjp/wDkR1YDGCDTwwAAAABJRU5ErkJggg==", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/tmp/ipykernel_1261920/3601518846.py:14: UserWarning: FigureCanvasAgg is non-interactive, and thus cannot be shown\n", + " plt.show()\n" + ] + } + ], + "source": [ + "import os\n", + "os.makedirs('_plots', exist_ok=True)\n", + "\n", + "fig, ax = plt.subplots(figsize=(6, 4))\n", + "ax.plot(kinetic_MeV * 511, flux, 'b-')\n", + "ax.set_xlabel('Kinetic Energy (keV)')\n", + "ax.set_ylabel('Flux')\n", + "ax.set_title('99Tc Beta Spectrum')\n", + "plt.tight_layout()\n", + "\n", + "plot_path = '_plots/smoke_test_plot.png'\n", + "fig.savefig(plot_path, dpi=100, bbox_inches='tight')\n", + "display(Image(filename=plot_path))\n", + "plt.show()" + ] + } + ], + "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.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index edaa882..05360ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ where = ["."] [project.optional-dependencies] dev = [ "pytest", + "nbmake", "black", "ruff", "mypy", @@ -41,7 +42,7 @@ strict = true line-length = 88 [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "notebooks"] [tool.ruff] line-length = 88 diff --git a/scripts/notebook_plots.py b/scripts/notebook_plots.py new file mode 100644 index 0000000..654dee9 --- /dev/null +++ b/scripts/notebook_plots.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Extract plots from executed notebooks and save as PNGs for LLM review. + +Usage: + python scripts/notebook_plots.py notebooks/smoke_test.ipynb + python scripts/notebook_plots.py notebooks/ # all notebooks + +Outputs PNG files to notebooks/_plots/ directory. +Each plot gets a descriptive filename based on notebook name and cell index. +""" + +import json +import os +import sys +from pathlib import Path + + +def extract_plots(notebook_path: str, output_dir: str = None) -> list: + """Extract all matplotlib figures from an executed notebook. + + Looks for PNG image data in cell outputs (standard Jupyter output format). + """ + nb_path = Path(notebook_path) + with open(nb_path) as f: + nb = json.load(f) + + if output_dir is None: + output_dir = nb_path.parent / "_plots" + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + plots = [] + nb_name = nb_path.stem + + for cell_idx, cell in enumerate(nb["cells"]): + if cell["cell_type"] != "code": + continue + for output in cell.get("outputs", []): + if output.get("output_type") != "display_data": + continue + # Jupyter stores images as base64 in data["image/png"] + data = output.get("data", {}) + png_b64 = data.get("image/png") + if png_b64: + plot_path = output_dir / f"{nb_name}_cell{cell_idx}.png" + import base64 + + with open(plot_path, "wb") as f: + f.write(base64.b64decode(png_b64)) + plots.append(str(plot_path)) + + return plots + + +def main(): + if len(sys.argv) < 2: + print("Usage: python scripts/notebook_plots.py ...") + print(" Pass a .ipynb file or a directory (processes all .ipynb files)") + sys.exit(1) + + all_plots = [] + for arg in sys.argv[1:]: + p = Path(arg) + if p.suffix == ".ipynb": + all_plots.extend(extract_plots(str(p))) + elif p.is_dir(): + for nb in sorted(p.glob("*.ipynb")): + if nb.name == "conftest.py": + continue + all_plots.extend(extract_plots(str(nb))) + + if not all_plots: + print("No plots found.") + sys.exit(0) + + print(f"Extracted {len(all_plots)} plot(s):") + for p in all_plots: + print(f" {p}") + print() + print("To review with LLM, send the PNG paths using @ notation:") + print(" @ " + " @ ".join(all_plots)) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_notebooks.py b/scripts/run_notebooks.py new file mode 100644 index 0000000..7ffd4c2 --- /dev/null +++ b/scripts/run_notebooks.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Execute notebooks and extract plots for LLM review. + +This script: +1. Executes all notebooks (or specified ones) using nbconvert +2. Saves executed notebooks with outputs +3. Extracts PNG figures from cell outputs +4. Reports plot locations for LLM analysis + +Usage: + python scripts/run_notebooks.py # all notebooks + python scripts/run_notebooks.py nb1.ipynb # specific notebook + python scripts/run_notebooks.py notebooks/ # directory +""" + +import json +import os +import sys +import base64 +from pathlib import Path + + +def execute_notebook(nb_path: str) -> bool: + """Execute a notebook using nbconvert and save outputs.""" + import nbformat + from nbclient import NotebookClient + + nb = nbformat.read(str(nb_path), as_version=4) + client = NotebookClient(nb, timeout=600, kernel_name="python3") + try: + client.execute() + nbformat.write(nb, str(nb_path)) + return True + except Exception as e: + print(f" ERROR: {e}") + return False + + +def extract_plots(nb_path: str, output_dir: str = None) -> list: + """Extract PNG figures from an executed notebook's cell outputs.""" + nb_path = Path(nb_path) + with open(nb_path) as f: + nb = json.load(f) + + if output_dir is None: + output_dir = nb_path.parent / "_plots" + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + plots = [] + nb_name = nb_path.stem + + for cell_idx, cell in enumerate(nb["cells"]): + if cell["cell_type"] != "code": + continue + for output in cell.get("outputs", []): + if output.get("output_type") != "display_data": + continue + data = output.get("data", {}) + png_b64 = data.get("image/png") + if png_b64: + plot_path = output_dir / f"{nb_name}_cell{cell_idx}.png" + with open(plot_path, "wb") as f: + f.write(base64.b64decode(png_b64)) + plots.append(str(plot_path)) + + return plots + + +def main(): + if len(sys.argv) < 2: + print("Usage: python scripts/run_notebooks.py ...") + sys.exit(1) + + all_plots = [] + failed = [] + + for arg in sys.argv[1:]: + p = Path(arg) + if p.suffix == ".ipynb": + print(f"Executing {p.name}...") + if execute_notebook(str(p)): + plots = extract_plots(str(p)) + all_plots.extend(plots) + print(f" Extracted {len(plots)} plot(s)") + else: + failed.append(p.name) + elif p.is_dir(): + for nb in sorted(p.glob("*.ipynb")): + if nb.name.startswith("_") or nb.name == "conftest.py": + continue + print(f"Executing {nb.name}...") + if execute_notebook(str(nb)): + plots = extract_plots(str(nb)) + all_plots.extend(plots) + print(f" Extracted {len(plots)} plot(s)") + else: + failed.append(nb.name) + + print() + print(f"Done. {len(all_plots)} plot(s) extracted, {len(failed)} notebook(s) failed.") + if all_plots: + print() + print("Plots saved to: notebooks/_plots/") + print("To review with LLM, send the PNG paths using @ notation:") + for p in all_plots: + print(f" @{p}") + + +if __name__ == "__main__": + main()