From 4660ba3c4cc8d8a72057daa0d1101649f0ec5cdd Mon Sep 17 00:00:00 2001 From: Melek Derman <48313913+melekderman@users.noreply.github.com> Date: Sun, 15 Feb 2026 00:45:14 -0800 Subject: [PATCH 1/5] add new changes, refactoring, and missing MT_MF --- .github/workflows/docs.yml | 23 +- .gitignore | 5 +- README.md | 94 +++-- docs/_static/.gitkeep | 0 docs/conf.py | 15 + docs/data_sources.rst | 123 ++++++ docs/getting_started.rst | 2 +- docs/index.rst | 10 +- docs/pipeline.rst | 2 +- pyepics/__init__.py | 10 +- pyepics/cli.py | 48 +-- pyepics/converters/__init__.py | 2 +- pyepics/converters/hdf5.py | 17 +- pyepics/converters/mcdc_hdf5.py | 10 +- pyepics/converters/raw_hdf5.py | 8 +- pyepics/exceptions.py | 4 +- pyepics/io/__init__.py | 4 +- pyepics/io/download.py | 30 +- pyepics/models/__init__.py | 2 +- pyepics/models/records.py | 2 +- pyepics/pyeedl_compat.py | 2 +- pyepics/readers/__init__.py | 2 +- pyepics/readers/base.py | 2 +- pyepics/readers/eadl.py | 5 +- pyepics/readers/eedl.py | 17 +- pyepics/readers/epdl.py | 9 +- pyepics/utils/__init__.py | 2 +- pyepics/utils/constants.py | 34 +- pyepics/utils/parsing.py | 2 +- pyepics/utils/validation.py | 2 +- reference_data/README.md | 20 - reference_data/reference_binding_energies.csv | 48 --- tests/conftest.py | 2 +- tests/fixtures/README.md | 21 + tests/fixtures/reference_binding_energies.csv | 101 +++++ .../fixtures}/reference_cross_sections.csv | 0 tests/generate_report.py | 280 ++++++++----- tests/regression_tests.ipynb | 373 +++++------------- tests/test_eadl.py | 2 +- tests/test_eedl.py | 2 +- tests/test_epdl.py | 2 +- tests/test_hdf5.py | 2 +- tests/test_mapping_completeness.py | 156 ++++++++ tests/test_pipeline.py | 2 +- 44 files changed, 941 insertions(+), 558 deletions(-) create mode 100644 docs/_static/.gitkeep create mode 100644 docs/data_sources.rst delete mode 100644 reference_data/README.md delete mode 100644 reference_data/reference_binding_energies.csv create mode 100644 tests/fixtures/README.md create mode 100644 tests/fixtures/reference_binding_energies.csv rename {reference_data => tests/fixtures}/reference_cross_sections.csv (100%) create mode 100644 tests/test_mapping_completeness.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index beaf3cf..e9fde53 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -26,10 +26,29 @@ jobs: python -m pip install --upgrade pip pip install numpy h5py endf sphinx sphinx-rtd-theme myst-parser - - name: Build docs + - name: Build docs (strict — fail on warnings) run: | cd docs - make html + make html SPHINXOPTS="-W --keep-going" + + - name: Verify autodoc coverage + run: | + cd docs + python -c " + import importlib, inspect, sys + sys.path.insert(0, '..') + import pyepics + public = [name for name in pyepics.__all__ if not name.startswith('_')] + missing = [] + for name in public: + obj = getattr(pyepics, name) + if callable(obj) and not inspect.getdoc(obj): + missing.append(name) + if missing: + print(f'Missing docstrings: {missing}') + sys.exit(1) + print(f'All {len(public)} public symbols have docstrings.') + " - name: Deploy to GitHub Pages if: github.ref == 'refs/heads/main' && github.event_name == 'push' diff --git a/.gitignore b/.gitignore index 528212f..94a2c01 100644 --- a/.gitignore +++ b/.gitignore @@ -3,8 +3,11 @@ __pycache__/ *.py[codz] *$py.class +# Data directory (downloaded ENDF, generated HDF5) +data/ + # Generated reports -reports/ +tests/reports/ # C extensions *.so diff --git a/README.md b/README.md index a7de99a..3061e23 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ > Python library for reading and converting EPICS (Electron Photon Interaction Cross Sections) nuclear data. -PyEPICS parses EEDL, EPDL, and EADL files from the [IAEA EPICS 2023](https://www-nds.iaea.org/epics/) database (in ENDF-6 format) and converts them into structured HDF5 files suitable for Monte Carlo transport codes such as [MC/DC](https://github.com/CEMeNT-PSAAP/MCDC). +PyEPICS parses EEDL, EPDL, and EADL files from the [LLNL EPICS 2025](https://nuclear.llnl.gov/EPICS/) database (in ENDF-6 format) and converts them into structured HDF5 files suitable for Monte Carlo transport codes such as [MC/DC](https://github.com/CEMeNT-PSAAP/MCDC). --- @@ -15,7 +15,7 @@ PyEPICS parses EEDL, EPDL, and EADL files from the [IAEA EPICS 2023](https://www ``` PyEPICS/ -├── pyepics/ +├── pyepics/ # Source code │ ├── __init__.py # Public API │ ├── cli.py # Batch processing CLI │ ├── exceptions.py # Custom exception hierarchy @@ -36,7 +36,20 @@ PyEPICS/ │ │ ├── parsing.py # ENDF format parsing helpers │ │ └── validation.py # Post-parse validation routines │ └── io/ -│ └── download.py # Dataset downloader from IAEA +│ └── download.py # Dataset downloader from LLNL +├── data/ # All data (gitignored) +│ ├── endf/ # Downloaded ENDF source files +│ │ ├── eedl/ # EEDL electron data +│ │ ├── epdl/ # EPDL photon data +│ │ └── eadl/ # EADL atomic relaxation data +│ ├── raw/ # Generated raw HDF5 files +│ │ ├── electron/ +│ │ ├── photon/ +│ │ └── atomic/ +│ └── mcdc/ # Generated MCDC HDF5 files +│ ├── electron/ +│ ├── photon/ +│ └── atomic/ └── tests/ ├── conftest.py # Shared pytest fixtures ├── test_eedl.py # EEDL reader + parsing + validation tests @@ -44,7 +57,9 @@ PyEPICS/ ├── test_eadl.py # EADL reader tests ├── test_hdf5.py # Legacy HDF5 converter tests ├── test_pipeline.py # Raw + MCDC pipeline tests - └── generate_report.py # PDF regression-test report generator + ├── generate_report.py # PDF regression-test report generator + ├── fixtures/ # Reference validation data + └── reports/ # Generated regression reports ``` ## Architecture @@ -61,14 +76,14 @@ utils ← models ← readers ← converters (raw_hdf5 / mcdc_hdf5) | **models** | Typed `dataclass` records (`EEDLDataset`, `EPDLDataset`, `EADLDataset`) — the sole output of readers and sole input to converters | | **readers** | `EEDLReader`, `EPDLReader`, `EADLReader` — parse ENDF files via the `endf` library and return model instances | | **converters** | Two-step conversion: `raw_hdf5` (full-fidelity) and `mcdc_hdf5` (transport-optimised) | -| **io** | Dataset download from IAEA | +| **io** | Dataset download from LLNL | | **cli** | Batch processing for the full pipeline | ## Installation ```bash pip install numpy h5py endf -# For downloading data from IAEA: +# For downloading data from LLNL: pip install requests beautifulsoup4 ``` @@ -76,26 +91,26 @@ pip install requests beautifulsoup4 ## Data Pipeline -PyEPICS follows a three-step pipeline, mirroring the PyEEDL workflow: +PyEPICS follows a three-step pipeline: ``` -IAEA website download +LLNL website download │ ─────────────────────► ▼ -eedl/ epdl/ eadl/ raw ENDF files (.endf) +data/endf/{eedl,epdl,eadl}/ raw ENDF files (.endf) │ ─────────────────────► ▼ -raw_data/ raw_data_photon/ raw HDF5 (original grids, breakpoints) -raw_data_atomic/ for external users +data/raw/{electron,photon, raw HDF5 (original grids, breakpoints) + atomic}/ for external users │ ─────────────────────► ▼ -mcdc_data/ mcdc_data_photon/ MCDC HDF5 (common grid, PDFs) -mcdc_data_atomic/ for transport codes +data/mcdc/{electron,photon, MCDC HDF5 (common grid, PDFs) + atomic}/ for transport codes ``` -### Step 1: Download ENDF Data from IAEA +### Step 1: Download ENDF Data from LLNL -Download all three EPICS libraries (EEDL, EPDL, EADL) from the IAEA Nuclear Data Services: +Download all three EPICS libraries (EEDL, EPDL, EADL) from LLNL Nuclear Data: ```bash # Download all libraries @@ -111,9 +126,9 @@ python -m pyepics.cli download --data-dir /path/to/data This creates three directories with `.endf` files: ``` -eedl/ ← EEDL.ZA001000.endf, EEDL.ZA002000.endf, ... (Z=1–100) -epdl/ ← EPDL.ZA001000.endf, ... -eadl/ ← EADL.ZA001000.endf, ... +data/endf/eedl/ ← EEDL.ZA001000.endf, EEDL.ZA002000.endf, ... (Z=1–100) +data/endf/epdl/ ← EPDL.ZA001000.endf, ... +data/endf/eadl/ ← EADL.ZA001000.endf, ... ``` ### Step 2: Create Raw HDF5 Files @@ -137,9 +152,9 @@ python -m pyepics.cli raw --overwrite Output directories: ``` -raw_data/ ← H.h5, He.h5, ..., Fe.h5, ... (electron) -raw_data_photon/ ← H.h5, He.h5, ... (photon) -raw_data_atomic/ ← H.h5, He.h5, ... (atomic relaxation) +data/raw/electron/ ← H.h5, He.h5, ..., Fe.h5, ... (electron) +data/raw/photon/ ← H.h5, He.h5, ... (photon) +data/raw/atomic/ ← H.h5, He.h5, ... (atomic relaxation) ``` ### Step 3: Create MCDC HDF5 Files @@ -160,9 +175,9 @@ python -m pyepics.cli mcdc --z-min 26 --z-max 26 # Fe only Output directories: ``` -mcdc_data/ ← H.h5, He.h5, ..., Fe.h5, ... (electron) -mcdc_data_photon/ ← H.h5, He.h5, ... (photon) -mcdc_data_atomic/ ← H.h5, He.h5, ... (atomic relaxation) +data/mcdc/electron/ ← H.h5, He.h5, ..., Fe.h5, ... (electron) +data/mcdc/photon/ ← H.h5, He.h5, ... (photon) +data/mcdc/atomic/ ← H.h5, He.h5, ... (atomic relaxation) ``` ### Full Pipeline (Raw + MCDC in One Step) @@ -186,14 +201,14 @@ You can also use the pipeline functions directly from Python: from pyepics import create_raw_hdf5, create_mcdc_hdf5 # Step 2: Raw HDF5 -create_raw_hdf5("EEDL", "eedl/EEDL.ZA026000.endf", "raw_data/Fe.h5", overwrite=True) +create_raw_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/raw/electron/Fe.h5", overwrite=True) # Step 3: MCDC HDF5 -create_mcdc_hdf5("EEDL", "eedl/EEDL.ZA026000.endf", "mcdc_data/Fe.h5", overwrite=True) +create_mcdc_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/mcdc/electron/Fe.h5", overwrite=True) # Download programmatically from pyepics.io.download import download_library, download_all -download_library("eedl") # downloads to ./eedl/ +download_library("eedl") # downloads to ./data/endf/eedl/ download_all() # downloads all three ``` @@ -206,7 +221,7 @@ from pyepics import EEDLReader # Parse an EEDL file reader = EEDLReader() -dataset = reader.read("eedl/EEDL.ZA026000.endf") +dataset = reader.read("data/endf/eedl/EEDL.ZA026000.endf") print(dataset.Z, dataset.symbol) # 26, "Fe" print(list(dataset.cross_sections.keys())) # ['xs_tot', 'xs_el', 'xs_lge', ...] ``` @@ -233,12 +248,33 @@ python -m pytest tests/ -v ## Backward Compatibility -A `pyeedl_compat` shim re-exports all legacy `pyeedl` symbols: +A `pyeedl_compat` shim re-exports legacy API symbols for backward compatibility: ```python from pyepics.pyeedl_compat import PERIODIC_TABLE, float_endf, SUBSHELL_LABELS ``` +## Data Sources + +PyEPICS uses the following authoritative data sources: + +| Data Type | Source | Reference | +|---|---|---| +| **Electron cross sections** | EEDL (Evaluated Electron Data Library) | [LLNL EPICS 2025](https://nuclear.llnl.gov/EPICS/) | +| **Photon cross sections** | EPDL (Evaluated Photon Data Library) | [LLNL EPICS 2025](https://nuclear.llnl.gov/EPICS/) | +| **Atomic relaxation** | EADL (Evaluated Atomic Data Library) | [LLNL EPICS 2025](https://nuclear.llnl.gov/EPICS/) | +| **Binding energies** | EADL via ENDF-6 format (not NIST) | Parsed from EADL `.endf` files | +| **Physical constants** | NIST CODATA 2018 | [NIST CODATA](https://physics.nist.gov/cuu/pdf/wallet_2018.pdf) | + +> **Note:** Binding energies are sourced from EADL (parsed from ENDF files), not from +> the NIST X-Ray Transition Energies database. The reference validation data in +> `tests/fixtures/reference_binding_energies.csv` is extracted from EEDL ENDF files +> and compared against the PyEPICS-parsed values to ensure round-trip consistency. + +## Acknowledgements + +This work was supported by the Center for Advancing the Radiation Resilience of Electronics (CARRE), a PSAAP-IV project funded by the Department of Energy, grant number: DE-NA0004268. + ## License Please see [LICENSE](LICENSE) for details. diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/conf.py b/docs/conf.py index 38d87b9..4303c8d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,11 @@ # Configuration file for the Sphinx documentation builder. +import os +import sys + +# Add parent directory to path so autodoc can import pyepics +sys.path.insert(0, os.path.abspath("..")) + project = "PyEPICS" copyright = "2026, Melek Derman" author = "Melek Derman" @@ -13,6 +19,15 @@ "myst_parser", ] +# Autodoc settings +autodoc_member_order = "bysource" +autodoc_typehints = "description" + +# Napoleon settings (Google/NumPy-style docstrings) +napoleon_google_docstring = True +napoleon_numpy_docstring = True +napoleon_include_init_with_doc = True + templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/docs/data_sources.rst b/docs/data_sources.rst new file mode 100644 index 0000000..070ca9e --- /dev/null +++ b/docs/data_sources.rst @@ -0,0 +1,123 @@ +Data Sources +============ + +PyEPICS processes evaluated nuclear data from the LLNL EPICS 2025 database. +This page documents the authoritative sources for each data type. + +ENDF Libraries +-------------- + +All cross-section, relaxation, and transition data are parsed from ENDF-6 +format files provided by LLNL: + +.. list-table:: + :header-rows: 1 + :widths: 25 40 35 + + * - Library + - Description + - Source + * - **EEDL** + - Evaluated Electron Data Library + - `LLNL EPICS 2025 `_ + * - **EPDL** + - Evaluated Photon Data Library + - `LLNL EPICS 2025 `_ + * - **EADL** + - Evaluated Atomic Data Library + - `LLNL EPICS 2025 `_ + +Binding Energies +---------------- + +Binding energies are sourced from the **EADL** (Evaluated Atomic Data Library), +parsed from the ENDF-6 format files. They are **not** sourced from the NIST +X-Ray Transition Energies database. + +The reference validation data in ``tests/fixtures/reference_binding_energies.csv`` +contains binding energies extracted from the EEDL ENDF files themselves. These +values are used for round-trip validation: the report generator compares +PyEPICS-parsed values against the ENDF source values to verify parsing +correctness. + +The analysis covers all available subshells (K, L1, L2, L3, M1, …) for +elements Z=1 through Z=100, not only the K-shell. + +Physical Constants +------------------ + +Physical constants are sourced from NIST CODATA 2018: + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - Constant + - Value + - Source + * - Fine-structure constant (α) + - 7.2973525693 × 10⁻³ + - NIST CODATA 2018 + * - Electron rest-mass energy (m_e c²) + - 0.51099895069 MeV + - NIST CODATA 2018 + * - Barn to cm² conversion + - 1 × 10⁻²⁴ + - Definition + * - Planck constant (h) + - 6.62607015 × 10⁻³⁴ J·s + - SI definition (exact) + * - Speed of light (c) + - 299 792 458 m/s + - SI definition (exact) + * - Elementary charge (e) + - 1.602176634 × 10⁻¹⁹ C + - SI definition (exact) + +MF/MT Mapping Tables +--------------------- + +Every section in an ENDF file is identified by a pair of integers +**(MF, MT)** — *MF* selects the data type (cross sections, distributions, +form factors, …) and *MT* selects the reaction or subshell. PyEPICS +keeps three mapping dictionaries in ``pyepics/utils/constants.py``: + +.. list-table:: + :header-rows: 1 + :widths: 30 20 50 + + * - Dictionary + - Library + - Purpose + * - ``MF_MT`` / ``SECTIONS_ABBREVS`` + - EEDL + - Electron cross-section & distribution sections + * - ``PHOTON_MF_MT`` / ``PHOTON_SECTIONS_ABBREVS`` + - EPDL + - Photon cross-section & form-factor sections + * - ``ATOMIC_MF_MT`` / ``ATOMIC_SECTIONS_ABBREVS`` + - EADL + - Atomic relaxation sections + +Each ``*_MF_MT`` dict maps ``(MF, MT)`` → human-readable description; each +``*_SECTIONS_ABBREVS`` dict maps the same keys → short mnemonic used as +HDF5 group names. + +**Adding a new (MF, MT) pair** — If a new evaluation introduces a +previously unseen section: + +1. Add the ``(MF, MT)`` key and its description to the appropriate + ``*_MF_MT`` dictionary, following the ENDF-6 Formats Manual [1]_ for + the correct meaning. +2. Add the same key with a short mnemonic to the matching + ``*_SECTIONS_ABBREVS`` dictionary. +3. Run ``pytest tests/test_mapping_completeness.py`` — it will flag any + ENDF (MF, MT) pair that lacks a mapping. + +.. [1] A. Trkov *et al.*, "ENDF-6 Formats Manual", BNL-90365-2009 Rev. 2, + https://www.nndc.bnl.gov/endfdocs/ENDF-102-2023.pdf + +Reference +--------- + +`NIST CODATA 2018 — Wallet Card (PDF) `_ diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 0b1c3e9..66a80b0 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -8,7 +8,7 @@ Installation pip install numpy h5py endf - # For downloading data from IAEA: + # For downloading data from LLNL: pip install requests beautifulsoup4 Quick Start diff --git a/docs/index.rst b/docs/index.rst index fc1b9da..e440154 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,16 +7,24 @@ PyEPICS Documentation getting_started pipeline + data_sources api Getting Started --------------- -PyEPICS parses EEDL, EPDL, and EADL files from the IAEA EPICS 2023 database +PyEPICS parses EEDL, EPDL, and EADL files from the LLNL EPICS 2025 database and converts them into structured HDF5 files for Monte Carlo transport codes. See :doc:`getting_started` for installation and quick start instructions. +Acknowledgements +---------------- + +This work was supported by the Center for Advancing the Radiation Resilience +of Electronics (CARRE), a PSAAP-IV project funded by the Department of Energy, +grant number: DE-NA0004268. + Indices and tables ================== diff --git a/docs/pipeline.rst b/docs/pipeline.rst index eea38e3..bbf477b 100644 --- a/docs/pipeline.rst +++ b/docs/pipeline.rst @@ -3,7 +3,7 @@ Data Pipeline PyEPICS follows a three-step pipeline: -1. **Download** ENDF files from IAEA +1. **Download** ENDF files from LLNL 2. **Raw HDF5** — full-fidelity, original grids 3. **MCDC HDF5** — transport-code optimised diff --git a/pyepics/__init__.py b/pyepics/__init__.py index 25ebe56..f9d6dd6 100644 --- a/pyepics/__init__.py +++ b/pyepics/__init__.py @@ -2,19 +2,19 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ PyEPICS - Python library for reading and converting EPICS nuclear data -Parse EEDL, EADL, and EPDL files from the IAEA EPICS (Electron Photon +Parse EEDL, EADL, and EPDL files from the LLNL EPICS 2025 (Electron Photon Interaction Cross Sections) database and convert them into structured HDF5 format suitable for Monte Carlo transport codes. Pipeline -------- -1. **Download** ENDF files from IAEA: +1. **Download** ENDF files from LLNL: ``python -m pyepics.cli download`` 2. **Raw HDF5** (full-fidelity, original grids): @@ -42,8 +42,8 @@ Examples -------- >>> from pyepics import EEDLReader, create_raw_hdf5, create_mcdc_hdf5 ->>> create_raw_hdf5("EEDL", "eedl/EEDL.ZA026000.endf", "raw_data/Fe.h5") ->>> create_mcdc_hdf5("EEDL", "eedl/EEDL.ZA026000.endf", "mcdc_data/Fe.h5") +>>> create_raw_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/raw/electron/Fe.h5") +>>> create_mcdc_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/mcdc/electron/Fe.h5") """ from __future__ import annotations diff --git a/pyepics/cli.py b/pyepics/cli.py index b251b82..d6115ce 100644 --- a/pyepics/cli.py +++ b/pyepics/cli.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -10,7 +10,7 @@ Provides batch-processing commands for the full data pipeline: -1. **download** — Download ENDF files from IAEA +1. **download** — Download ENDF files from LLNL 2. **raw** — Create raw HDF5 files (original grids, breakpoints) 3. **mcdc** — Create MCDC-format HDF5 files (common grid, PDFs) 4. **all** — Run raw + mcdc for a set of libraries @@ -33,15 +33,19 @@ Directory structure after a full run:: - eedl/ ← downloaded ENDF (EEDL) - epdl/ ← downloaded ENDF (EPDL) - eadl/ ← downloaded ENDF (EADL) - raw_data/ ← raw HDF5 (electron) - raw_data_photon/ ← raw HDF5 (photon) - raw_data_atomic/ ← raw HDF5 (atomic) - mcdc_data/ ← MCDC HDF5 (electron) - mcdc_data_photon/ ← MCDC HDF5 (photon) - mcdc_data_atomic/ ← MCDC HDF5 (atomic) + data/ + endf/ + eedl/ ← downloaded ENDF (EEDL) + epdl/ ← downloaded ENDF (EPDL) + eadl/ ← downloaded ENDF (EADL) + raw/ + electron/ ← raw HDF5 (electron) + photon/ ← raw HDF5 (photon) + atomic/ ← raw HDF5 (atomic) + mcdc/ + electron/ ← MCDC HDF5 (electron) + photon/ ← MCDC HDF5 (photon) + atomic/ ← MCDC HDF5 (atomic) """ from __future__ import annotations @@ -63,26 +67,26 @@ LIBRARY_CONFIG = { "electron": { "dataset_type": "EEDL", - "endf_dir": "eedl", + "endf_dir": "data/endf/eedl", "endf_prefix": "EEDL", - "raw_dir": "raw_data", - "mcdc_dir": "mcdc_data", + "raw_dir": "data/raw/electron", + "mcdc_dir": "data/mcdc/electron", "download_key": "eedl", }, "photon": { "dataset_type": "EPDL", - "endf_dir": "epdl", + "endf_dir": "data/endf/epdl", "endf_prefix": "EPDL", - "raw_dir": "raw_data_photon", - "mcdc_dir": "mcdc_data_photon", + "raw_dir": "data/raw/photon", + "mcdc_dir": "data/mcdc/photon", "download_key": "epdl", }, "atomic": { "dataset_type": "EADL", - "endf_dir": "eadl", + "endf_dir": "data/endf/eadl", "endf_prefix": "EADL", - "raw_dir": "raw_data_atomic", - "mcdc_dir": "mcdc_data_atomic", + "raw_dir": "data/raw/atomic", + "mcdc_dir": "data/mcdc/atomic", "download_key": "eadl", }, } @@ -113,7 +117,7 @@ def _element_symbol(Z: int) -> str: # --------------------------------------------------------------------------- def cmd_download(args): - """Download ENDF files from IAEA.""" + """Download ENDF files from LLNL.""" from pyepics.io.download import download_library base = Path(args.data_dir) @@ -317,7 +321,7 @@ def build_parser() -> argparse.ArgumentParser: # Subcommands sub = parser.add_subparsers(dest="command", help="Pipeline step to run") - sub.add_parser("download", help="Download ENDF files from IAEA") + sub.add_parser("download", help="Download ENDF files from LLNL") sub.add_parser("raw", help="Create raw HDF5 files") sub.add_parser("mcdc", help="Create MCDC-format HDF5 files") sub.add_parser("all", help="Run raw + mcdc (full pipeline)") diff --git a/pyepics/converters/__init__.py b/pyepics/converters/__init__.py index e3ea204..e83e385 100644 --- a/pyepics/converters/__init__.py +++ b/pyepics/converters/__init__.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/converters/hdf5.py b/pyepics/converters/hdf5.py index 9329818..e624e6b 100644 --- a/pyepics/converters/hdf5.py +++ b/pyepics/converters/hdf5.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -154,10 +154,9 @@ def _create_xs_dataset( def _write_eedl(h5f: h5py.File, dataset: EEDLDataset) -> None: """Write an EEDL dataset to the ``/EEDL/Z_{ZZZ}`` group - Reproduces the MCDC-compatible layout used by the original PyEEDL - pipeline, including interpolation of all cross sections onto a - common energy grid and computation of small-angle scattering - cosine distributions. + Produces the MCDC-compatible layout, including interpolation of all + cross sections onto a common energy grid and computation of + small-angle scattering cosine distributions. Parameters ---------- @@ -184,6 +183,7 @@ def _write_eedl(h5f: h5py.File, dataset: EEDLDataset) -> None: # Helper to interpolate onto grid def interp(key: str) -> np.ndarray: + """Interpolate cross-section *key* onto the common energy grid.""" if key in xs: return linear_interpolation(xs_energy_grid, xs[key].energy, xs[key].cross_section) return np.zeros_like(xs_energy_grid) @@ -306,6 +306,7 @@ def _write_epdl(h5f: h5py.File, dataset: EPDLDataset) -> None: xs_energy_grid = xs["xs_tot"].energy def interp(key: str) -> np.ndarray: + """Interpolate cross-section *key* onto the common energy grid.""" if key in xs: return linear_interpolation(xs_energy_grid, xs[key].energy, xs[key].cross_section) return np.zeros_like(xs_energy_grid) @@ -516,7 +517,7 @@ def convert_dataset_to_hdf5( -------- >>> convert_dataset_to_hdf5( ... "EEDL", - ... "eedl/EEDL.ZA026000.endf", + ... "data/endf/eedl/EEDL.ZA026000.endf", ... "output/Fe.h5", ... overwrite=True, ... ) @@ -615,7 +616,7 @@ def create_raw_hdf5( Examples -------- - >>> create_raw_hdf5("EEDL", "eedl/EEDL.ZA026000.endf", "raw_data/Fe.h5") + >>> create_raw_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/raw/electron/Fe.h5") """ from pyepics.converters.raw_hdf5 import ( write_raw_eedl, @@ -677,7 +678,7 @@ def create_mcdc_hdf5( Examples -------- - >>> create_mcdc_hdf5("EEDL", "eedl/EEDL.ZA026000.endf", "mcdc_data/Fe.h5") + >>> create_mcdc_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/mcdc/electron/Fe.h5") """ from pyepics.converters.mcdc_hdf5 import ( write_mcdc_eedl, diff --git a/pyepics/converters/mcdc_hdf5.py b/pyepics/converters/mcdc_hdf5.py index db19932..6427b65 100644 --- a/pyepics/converters/mcdc_hdf5.py +++ b/pyepics/converters/mcdc_hdf5.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -28,9 +28,9 @@ Output Directories ------------------ -* ``mcdc_data/`` — EEDL (electron) -* ``mcdc_data_photon/`` — EPDL (photon) -* ``mcdc_data_atomic/`` — EADL (atomic) +* ``data/mcdc/electron/`` — EEDL (electron) +* ``data/mcdc/photon/`` — EPDL (photon) +* ``data/mcdc/atomic/`` — EADL (atomic) See Also -------- @@ -125,6 +125,7 @@ def write_mcdc_eedl(h5f: h5py.File, dataset: EEDLDataset) -> None: # Interpolation helper def interp(key: str) -> np.ndarray: + """Interpolate cross-section *key* onto the common energy grid.""" if key in xs: return linear_interpolation( xs_energy_grid, xs[key].energy, xs[key].cross_section, @@ -258,6 +259,7 @@ def write_mcdc_epdl(h5f: h5py.File, dataset: EPDLDataset) -> None: xs_energy_grid = xs["xs_tot"].energy def interp(key: str) -> np.ndarray: + """Interpolate cross-section *key* onto the common energy grid.""" if key in xs: return linear_interpolation( xs_energy_grid, xs[key].energy, xs[key].cross_section, diff --git a/pyepics/converters/raw_hdf5.py b/pyepics/converters/raw_hdf5.py index 0bd31dd..ed1e5ae 100644 --- a/pyepics/converters/raw_hdf5.py +++ b/pyepics/converters/raw_hdf5.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -18,9 +18,9 @@ Output Directories ------------------ -* ``raw_data/`` — EEDL (electron) raw files -* ``raw_data_photon/`` — EPDL (photon) raw files -* ``raw_data_atomic/`` — EADL (atomic) raw files +* ``data/raw/electron/`` — EEDL (electron) raw files +* ``data/raw/photon/`` — EPDL (photon) raw files +* ``data/raw/atomic/`` — EADL (atomic) raw files HDF5 Layout — EEDL ------------------- diff --git a/pyepics/exceptions.py b/pyepics/exceptions.py index 685015b..a9205c0 100644 --- a/pyepics/exceptions.py +++ b/pyepics/exceptions.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -98,7 +98,7 @@ class ConversionError(PyEPICSError): class DownloadError(PyEPICSError): - """Raised when dataset download from IAEA fails + """Raised when dataset download from LLNL fails Reserved for future use by the ``io.download`` module. Covers HTTP errors, connection timeouts, and checksum mismatches. diff --git a/pyepics/io/__init__.py b/pyepics/io/__init__.py index db3c4a9..1b68e4e 100644 --- a/pyepics/io/__init__.py +++ b/pyepics/io/__init__.py @@ -2,11 +2,11 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ -I/O utilities for downloading EPICS datasets from IAEA +I/O utilities for downloading EPICS 2025 datasets from LLNL .. note:: The download functionality is a stub reserved for future diff --git a/pyepics/io/download.py b/pyepics/io/download.py index b2d96dd..6318ca4 100644 --- a/pyepics/io/download.py +++ b/pyepics/io/download.py @@ -2,26 +2,26 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ EPICS dataset downloader -Downloads EEDL, EPDL, and EADL ENDF files from the IAEA Nuclear Data -Services website. +Downloads EEDL, EPDL, and EADL ENDF files from the LLNL Nuclear Data +website (EPICS 2025). Data Sources ------------ -* EEDL: ``https://www-nds.iaea.org/epics/ENDF2023/EEDL.ELEMENTS/`` -* EPDL: ``https://www-nds.iaea.org/epics/ENDF2023/EPDL.ELEMENTS/`` -* EADL: ``https://www-nds.iaea.org/epics/ENDF2023/EADL.ELEMENTS/`` +* EEDL: ``https://nuclear.llnl.gov/EPICS/ENDF2025/EEDL.ELEMENTS/`` +* EPDL: ``https://nuclear.llnl.gov/EPICS/ENDF2025/EPDL.ELEMENTS/`` +* EADL: ``https://nuclear.llnl.gov/EPICS/ENDF2025/EADL.ELEMENTS/`` Examples -------- >>> from pyepics.io.download import download_library, download_all ->>> download_library("eedl") # downloads to ./eedl/ ->>> download_all(out_dir="data") # downloads all three to data/{lib}/ +>>> download_library("eedl") # downloads to ./data/endf/eedl/ +>>> download_all(out_dir="data/endf") # downloads all three """ from __future__ import annotations @@ -41,17 +41,17 @@ LIBRARY_URLS: dict[str, dict[str, str]] = { "eedl": { - "url": "https://www-nds.iaea.org/epics/ENDF2023/EEDL.ELEMENTS/getza.htm", + "url": "https://nuclear.llnl.gov/EPICS/ENDF2025/EEDL.ELEMENTS/getza.htm", "prefix": "EEDL", "description": "Evaluated Electron Data Library", }, "epdl": { - "url": "https://www-nds.iaea.org/epics/ENDF2023/EPDL.ELEMENTS/getza.htm", + "url": "https://nuclear.llnl.gov/EPICS/ENDF2025/EPDL.ELEMENTS/getza.htm", "prefix": "EPDL", "description": "Evaluated Photon Data Library", }, "eadl": { - "url": "https://www-nds.iaea.org/epics/ENDF2023/EADL.ELEMENTS/getza.htm", + "url": "https://nuclear.llnl.gov/EPICS/ENDF2025/EADL.ELEMENTS/getza.htm", "prefix": "EADL", "description": "Evaluated Atomic Data Library", }, @@ -67,9 +67,9 @@ def download_library( library_name: Literal["eedl", "epdl", "eadl"], out_dir: Path | str | None = None, ) -> Path: - """Download a specific EPICS library from the IAEA website + """Download a specific EPICS library from the LLNL website - Fetches the IAEA index page for the requested library, discovers all + Fetches the LLNL index page for the requested library, discovers all element ENDF files, and downloads each one to *out_dir*. Parameters @@ -137,7 +137,7 @@ def download_library( if not links: raise DownloadError( f"No download links found on {base_url}. " - "The IAEA page format may have changed." + "The LLNL page format may have changed." ) n_downloaded = 0 @@ -173,7 +173,7 @@ def download_all(out_dir: Path | str | None = None) -> dict[str, Path]: ---------- out_dir : Path | str | None, optional Parent output directory. Each library will be placed in a - sub-folder (``eedl/``, ``epdl/``, ``eadl/``). Defaults to + sub-folder (``data/endf/eedl/``, etc.). Defaults to the current working directory. Returns diff --git a/pyepics/models/__init__.py b/pyepics/models/__init__.py index 688d8f7..404a859 100644 --- a/pyepics/models/__init__.py +++ b/pyepics/models/__init__.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/models/records.py b/pyepics/models/records.py index cdb8e07..b1f7c63 100644 --- a/pyepics/models/records.py +++ b/pyepics/models/records.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/pyeedl_compat.py b/pyepics/pyeedl_compat.py index 25706cb..e21a0c6 100644 --- a/pyepics/pyeedl_compat.py +++ b/pyepics/pyeedl_compat.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/readers/__init__.py b/pyepics/readers/__init__.py index 2e22142..529abed 100644 --- a/pyepics/readers/__init__.py +++ b/pyepics/readers/__init__.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/readers/base.py b/pyepics/readers/base.py index 7ed4104..6e1e076 100644 --- a/pyepics/readers/base.py +++ b/pyepics/readers/base.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/readers/eadl.py b/pyepics/readers/eadl.py index a193623..edb8fdd 100644 --- a/pyepics/readers/eadl.py +++ b/pyepics/readers/eadl.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -29,7 +29,8 @@ References ---------- .. [1] ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2), §28. -.. [2] IAEA Nuclear Data Services — EPICS 2023. +.. [2] LLNL Nuclear Data — EPICS 2025. + https://nuclear.llnl.gov/EPICS/ """ from __future__ import annotations diff --git a/pyepics/readers/eedl.py b/pyepics/readers/eedl.py index 4159a0b..04ab66d 100644 --- a/pyepics/readers/eedl.py +++ b/pyepics/readers/eedl.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -32,7 +32,8 @@ References ---------- .. [1] ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2). -.. [2] IAEA Nuclear Data Services — EPICS 2023. +.. [2] LLNL Nuclear Data — EPICS 2025. + https://nuclear.llnl.gov/EPICS/ """ from __future__ import annotations @@ -80,7 +81,7 @@ class EEDLReader(BaseReader): Extracts electron interaction cross sections (MF=23) and angular / energy distributions (MF=26) from a single-element ENDF file generated - by the IAEA EPICS 2023 pipeline. + by the LLNL EPICS 2025 pipeline. The reader produces an :class:`~pyepics.models.records.EEDLDataset` dataclass that can be passed directly to the HDF5 converter. @@ -269,11 +270,11 @@ def read( for idx, sub in enumerate(sub_list): E_out = sub.get("E'", []) b_raw = sub.get("b") - if b_raw is not None: - for eo, bb in zip(E_out, b_raw): - inc_e_arr.append(E_inc[idx]) - out_e_arr.append(eo) - b_arr.append(float(bb)) + b_flat = b_raw.flatten() if b_raw is not None else [] + for eo, bb in zip(E_out, b_flat): + inc_e_arr.append(E_inc[idx]) + out_e_arr.append(eo) + b_arr.append(float(bb)) if inc_e_arr: bremsstrahlung_spectra = DistributionRecord( label=abbrev, diff --git a/pyepics/readers/epdl.py b/pyepics/readers/epdl.py index 0b83d6f..677137a 100644 --- a/pyepics/readers/epdl.py +++ b/pyepics/readers/epdl.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -26,7 +26,8 @@ References ---------- .. [1] ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2). -.. [2] IAEA Nuclear Data Services — EPICS 2023. +.. [2] LLNL Nuclear Data — EPICS 2025. + https://nuclear.llnl.gov/EPICS/ """ from __future__ import annotations @@ -68,8 +69,8 @@ class EPDLReader(BaseReader): """Reader for EPDL (Evaluated Photon Data Library) ENDF files Extracts photon interaction cross sections (MF=23) and form factors - (MF=27) from a single-element ENDF file generated by the IAEA EPICS - 2023 pipeline. + (MF=27) from a single-element ENDF file generated by the LLNL EPICS + 2025 pipeline. Notes ----- diff --git a/pyepics/utils/__init__.py b/pyepics/utils/__init__.py index aa6e6c9..bbf2ee4 100644 --- a/pyepics/utils/__init__.py +++ b/pyepics/utils/__init__.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/utils/constants.py b/pyepics/utils/constants.py index 5db0bcb..3f0db04 100644 --- a/pyepics/utils/constants.py +++ b/pyepics/utils/constants.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -221,6 +221,9 @@ # --------------------------------------------------------------------------- MF_MT: dict[tuple[int, int], str] = { + # MF=1 : General Information / Directory + # ENDF-6 §1.1 — every material begins with MF=1/MT=451 descriptive data. + (1, 451): "General Information / Directory", # MF=23 : Electron Cross Sections (23, 501): "Total Electron Cross Sections", (23, 522): "Ionization (sum of subshells)", @@ -251,10 +254,22 @@ (23, 554): "O5 (5d5/2) Electroionization Subshell Cross Sections", (23, 555): "O6 (5f5/2) Electroionization Subshell Cross Sections", (23, 556): "O7 (5f7/2) Electroionization Subshell Cross Sections", + (23, 557): "O8 (5g7/2) Electroionization Subshell Cross Sections", + (23, 558): "O9 (5g9/2) Electroionization Subshell Cross Sections", (23, 559): "P1 (6s1/2) Electroionization Subshell Cross Sections", (23, 560): "P2 (6p1/2) Electroionization Subshell Cross Sections", (23, 561): "P3 (6p3/2) Electroionization Subshell Cross Sections", + (23, 562): "P4 (6d3/2) Electroionization Subshell Cross Sections", + (23, 563): "P5 (6d5/2) Electroionization Subshell Cross Sections", + (23, 564): "P6 (6f5/2) Electroionization Subshell Cross Sections", + (23, 565): "P7 (6f7/2) Electroionization Subshell Cross Sections", + (23, 566): "P8 (6g7/2) Electroionization Subshell Cross Sections", + (23, 567): "P9 (6g9/2) Electroionization Subshell Cross Sections", + (23, 568): "P10 (6h7/2) Electroionization Subshell Cross Sections", + (23, 569): "P11 (6h9/2) Electroionization Subshell Cross Sections", (23, 570): "Q1 (7s1/2) Electroionization Subshell Cross Sections", + (23, 571): "Q2 (7p1/2) Electroionization Subshell Cross Sections", + (23, 572): "Q3 (7p3/2) Electroionization Subshell Cross Sections", # MF=26 : Angular and Energy Distributions (26, 525): "Large Angle Elastic Angular Distributions", (26, 527): "Bremsstrahlung Photon Energy Spectra and Electron Average Energy Loss", @@ -302,6 +317,8 @@ """Human-readable descriptions for every EEDL (MF, MT) section pair.""" SECTIONS_ABBREVS: dict[tuple[int, int], str] = { + # MF=1 general information + (1, 451): "general_info", # MF=23 cross sections (23, 501): "xs_tot", (23, 522): "xs_ion", (23, 525): "xs_lge", (23, 526): "xs_el", @@ -346,10 +363,14 @@ # --------------------------------------------------------------------------- PHOTON_MF_MT: dict[tuple[int, int], str] = { + # MF=1 : General Information / Directory + # ENDF-6 §1.1 — every material begins with MF=1/MT=451 descriptive data. + (1, 451): "General Information / Directory", (23, 501): "Total Photon Cross Section", (23, 502): "Coherent (Rayleigh) Scattering Cross Section", (23, 504): "Incoherent (Compton) Scattering Cross Section", (23, 516): "Pair Production Cross Section (Total)", + (23, 515): "Pair Production Cross Section (Electron Field)", (23, 517): "Pair Production Cross Section (Nuclear Field)", (23, 518): "Pair Production Cross Section (Electron Field - Triplet)", (23, 522): "Total Photoelectric Cross Section", @@ -381,6 +402,8 @@ (23, 559): "P1 (6s1/2) Photoelectric Subshell Cross Section", (23, 560): "P2 (6p1/2) Photoelectric Subshell Cross Section", (23, 561): "P3 (6p3/2) Photoelectric Subshell Cross Section", + (23, 562): "P4 (6d3/2) Photoelectric Subshell Cross Section", + (23, 563): "P5 (6d5/2) Photoelectric Subshell Cross Section", (23, 570): "Q1 (7s1/2) Photoelectric Subshell Cross Section", (27, 502): "Coherent Scattering Form Factor", (27, 504): "Incoherent Scattering Function", @@ -389,8 +412,10 @@ } PHOTON_SECTIONS_ABBREVS: dict[tuple[int, int], str] = { + (1, 451): "general_info", (23, 501): "xs_tot", (23, 502): "xs_coherent", (23, 504): "xs_incoherent", + (23, 515): "xs_pair_efield", (23, 516): "xs_pair_total", (23, 517): "xs_pair_nuclear", (23, 518): "xs_pair_electron", (23, 522): "xs_photoelectric", (23, 534): "xs_pe_K", (23, 535): "xs_pe_L1", (23, 536): "xs_pe_L2", @@ -403,6 +428,7 @@ (23, 553): "xs_pe_O4", (23, 554): "xs_pe_O5", (23, 555): "xs_pe_O6", (23, 556): "xs_pe_O7", (23, 557): "xs_pe_O8", (23, 558): "xs_pe_O9", (23, 559): "xs_pe_P1", (23, 560): "xs_pe_P2", (23, 561): "xs_pe_P3", + (23, 562): "xs_pe_P4", (23, 563): "xs_pe_P5", (23, 570): "xs_pe_Q1", (27, 502): "ff_coherent", (27, 504): "sf_incoherent", (27, 505): "asf_imag", (27, 506): "asf_real", @@ -414,10 +440,14 @@ # --------------------------------------------------------------------------- ATOMIC_MF_MT: dict[tuple[int, int], str] = { + # MF=1 : General Information / Directory + # ENDF-6 §1.1 — every material begins with MF=1/MT=451 descriptive data. + (1, 451): "General Information / Directory", (28, 533): "Atomic Relaxation Data", } ATOMIC_SECTIONS_ABBREVS: dict[tuple[int, int], str] = { + (1, 451): "general_info", (28, 533): "atomic_relax", } @@ -433,6 +463,7 @@ "sigma.interpolation": "INT: interpolation law code for each region", "ZA": "ZA identifier of the target (Z × 1000 + A)", "AWR": "Atomic weight ratio of the target", + "LRF": "Resonance/interpolation flag (if present)", } MF26: dict[str, str] = { @@ -445,6 +476,7 @@ "distribution.NR": "Number of interpolation regions", "distribution.NE": "Number of incident-energy points", "distribution.E": "Array of incident energies (eV)", + "distribution.E_int": "Tabulated2D full energy–energy distribution object", "distribution.distribution.ND": "Number of discrete outgoing-energy points", "distribution.distribution.NA": "Number of angular parameters", "distribution.distribution.NW": "Total words in the LIST record", diff --git a/pyepics/utils/parsing.py b/pyepics/utils/parsing.py index 4a84cc3..d8bcba9 100644 --- a/pyepics/utils/parsing.py +++ b/pyepics/utils/parsing.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/pyepics/utils/validation.py b/pyepics/utils/validation.py index 5b7d340..fd93a58 100644 --- a/pyepics/utils/validation.py +++ b/pyepics/utils/validation.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/reference_data/README.md b/reference_data/README.md deleted file mode 100644 index 8ab3fa7..0000000 --- a/reference_data/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# PyEPICS Reference Data - -This directory contains reference data for regression testing and validation -of PyEPICS against the IAEA EPICS 2023 database. - -## Files - -| File | Description | -|---|---| -| `reference_binding_energies.csv` | NIST X-Ray Transition Energies binding energies for select elements (eV) | -| `reference_cross_sections.csv` | Spot-check cross-section values from EPICS 2023 documentation | -| `.gitkeep` | Placeholder for HDF5 output files produced during test runs | - -## Sources - -- **Binding Energies**: NIST X-Ray Transition Energies Database - -- **Cross Sections**: IAEA EPICS 2023 evaluated data - -- **Atomic Relaxation**: EADL (Evaluated Atomic Data Library) via ENDF-6 format diff --git a/reference_data/reference_binding_energies.csv b/reference_data/reference_binding_energies.csv deleted file mode 100644 index fb9de4a..0000000 --- a/reference_data/reference_binding_energies.csv +++ /dev/null @@ -1,48 +0,0 @@ -element,Z,subshell,binding_energy_eV,source -H,1,K,13.6,NIST -He,2,K,24.6,NIST -Li,3,K,54.7,NIST -Be,4,K,111.5,NIST -B,5,K,188.0,NIST -C,6,K,284.2,NIST -N,7,K,409.9,NIST -O,8,K,543.1,NIST -F,9,K,696.7,NIST -Ne,10,K,870.2,NIST -Na,11,K,1070.8,NIST -Mg,12,K,1303.0,NIST -Al,13,K,1559.6,NIST -Si,14,K,1839.0,NIST -P,15,K,2145.5,NIST -S,16,K,2472.0,NIST -Cl,17,K,2822.4,NIST -Ar,18,K,3205.9,NIST -K,19,K,3608.4,NIST -Ca,20,K,4038.5,NIST -Ti,22,K,4966.0,NIST -Fe,26,K,7112.0,NIST -Cu,29,K,8979.0,NIST -Zn,30,K,9659.0,NIST -Ge,32,K,11103.0,NIST -Mo,42,K,20000.0,NIST -Ag,47,K,25514.0,NIST -Sn,50,K,29200.0,NIST -W,74,K,69525.0,NIST -Au,79,K,80725.0,NIST -Pb,82,K,88005.0,NIST -U,92,K,115606.0,NIST -Fe,26,L1,844.6,NIST -Fe,26,L2,719.9,NIST -Fe,26,L3,706.8,NIST -Cu,29,L1,1096.7,NIST -Cu,29,L2,952.3,NIST -Cu,29,L3,932.7,NIST -Au,79,L1,14353.0,NIST -Au,79,L2,13734.0,NIST -Au,79,L3,11919.0,NIST -Pb,82,L1,15861.0,NIST -Pb,82,L2,15200.0,NIST -Pb,82,L3,13035.0,NIST -U,92,L1,21757.0,NIST -U,92,L2,20948.0,NIST -U,92,L3,17166.0,NIST diff --git a/tests/conftest.py b/tests/conftest.py index 23073ed..40d0494 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..bd48cf9 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,21 @@ +# PyEPICS Reference Data + +This directory contains reference data for regression testing and validation +of PyEPICS against the LLNL EPICS 2025 database. + +## Files + +| File | Description | +|---|---| +| `reference_binding_energies.csv` | Binding energies extracted from EEDL ENDF files for select elements (eV) | +| `reference_cross_sections.csv` | Spot-check cross-section values from EPICS 2025 documentation | +| `.gitkeep` | Placeholder for HDF5 output files produced during test runs | + +## Sources + +- **Binding Energies**: Extracted from EEDL (Evaluated Electron Data Library) + ENDF-6 format files. These are **not** from NIST X-Ray Transition Energies. + The values are used for round-trip validation of PyEPICS parsing. +- **Cross Sections**: LLNL EPICS 2025 evaluated data + +- **Atomic Relaxation**: EADL (Evaluated Atomic Data Library) via ENDF-6 format diff --git a/tests/fixtures/reference_binding_energies.csv b/tests/fixtures/reference_binding_energies.csv new file mode 100644 index 0000000..217f7a5 --- /dev/null +++ b/tests/fixtures/reference_binding_energies.csv @@ -0,0 +1,101 @@ +element,Z,subshell,binding_energy_eV,source +Ac,89,K,106760.0,EEDL +Ag,47,K,25520.0,EEDL +Al,13,K,1564.0,EEDL +Am,95,K,124980.0,EEDL +Ar,18,K,3206.3,EEDL +As,33,K,11871.0,EEDL +At,85,K,95729.0,EEDL +Au,79,K,80729.0,EEDL +B,5,K,192.0,EEDL +Ba,56,K,37442.0,EEDL +Be,4,K,115.0,EEDL +Bi,83,K,90534.0,EEDL +Bk,97,K,131590.0,EEDL +Br,35,K,13481.0,EEDL +C,6,K,288.0,EEDL +Ca,20,K,4041.0,EEDL +Cd,48,K,26715.0,EEDL +Ce,58,K,40446.0,EEDL +Cf,98,K,134970.0,EEDL +Cl,17,K,2829.0,EEDL +Cm,96,K,128260.0,EEDL +Co,27,K,7715.0,EEDL +Cr,24,K,5995.0,EEDL +Cs,55,K,35987.0,EEDL +Cu,29,K,8986.0,EEDL +Dy,66,K,53792.0,EEDL +Er,68,K,57489.0,EEDL +Es,99,K,138440.0,EEDL +Eu,63,K,48522.0,EEDL +F,9,K,694.0,EEDL +Fe,26,K,7117.0,EEDL +Fm,100,K,141962.0,EEDL +Fr,87,K,101130.0,EEDL +Ga,31,K,10371.0,EEDL +Gd,64,K,50243.0,EEDL +Ge,32,K,11107.0,EEDL +H,1,K,13.6,EEDL +He,2,K,24.59,EEDL +Hf,72,K,65350.0,EEDL +Hg,80,K,83108.0,EEDL +Ho,67,K,55622.0,EEDL +I,53,K,33176.0,EEDL +In,49,K,27944.0,EEDL +Ir,77,K,76115.0,EEDL +K,19,K,3610.0,EEDL +Kr,36,K,14327.0,EEDL +La,57,K,38928.0,EEDL +Li,3,K,58.0,EEDL +Lu,71,K,63320.0,EEDL +Mg,12,K,1308.0,EEDL +Mn,25,K,6544.0,EEDL +Mo,42,K,20006.0,EEDL +N,7,K,403.0,EEDL +Na,11,K,1075.0,EEDL +Nb,41,K,18990.0,EEDL +Nd,60,K,43575.0,EEDL +Ne,10,K,870.1,EEDL +Ni,28,K,8338.0,EEDL +Np,93,K,118680.0,EEDL +O,8,K,538.0,EEDL +Os,76,K,73876.0,EEDL +P,15,K,2148.0,EEDL +Pa,91,K,112600.0,EEDL +Pb,82,K,88011.0,EEDL +Pd,46,K,24357.0,EEDL +Pm,61,K,45188.0,EEDL +Po,84,K,93106.0,EEDL +Pr,59,K,41995.0,EEDL +Pt,78,K,78399.0,EEDL +Pu,94,K,121800.0,EEDL +Ra,88,K,103920.0,EEDL +Rb,37,K,15203.0,EEDL +Re,75,K,71681.0,EEDL +Rh,45,K,23225.0,EEDL +Rn,86,K,98404.0,EEDL +Ru,44,K,22123.0,EEDL +S,16,K,2476.0,EEDL +Sb,51,K,30496.0,EEDL +Sc,21,K,4494.0,EEDL +Se,34,K,12662.0,EEDL +Si,14,K,1844.0,EEDL +Sm,62,K,46837.0,EEDL +Sn,50,K,29204.0,EEDL +Sr,38,K,16108.0,EEDL +Ta,73,K,67419.0,EEDL +Tb,65,K,51999.0,EEDL +Tc,43,K,21050.0,EEDL +Te,52,K,31820.0,EEDL +Th,90,K,109650.0,EEDL +Ti,22,K,4970.0,EEDL +Tl,81,K,85536.0,EEDL +Tm,69,K,59393.0,EEDL +U,92,K,115610.0,EEDL +V,23,K,5470.0,EEDL +W,74,K,69529.0,EEDL +Xe,54,K,34565.0,EEDL +Y,39,K,17041.0,EEDL +Yb,70,K,61335.0,EEDL +Zn,30,K,9663.0,EEDL +Zr,40,K,18002.0,EEDL diff --git a/reference_data/reference_cross_sections.csv b/tests/fixtures/reference_cross_sections.csv similarity index 100% rename from reference_data/reference_cross_sections.csv rename to tests/fixtures/reference_cross_sections.csv diff --git a/tests/generate_report.py b/tests/generate_report.py index b4130ca..35a5d37 100644 --- a/tests/generate_report.py +++ b/tests/generate_report.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ @@ -13,10 +13,10 @@ * Unit-test summary (pytest) * Electron / photon cross-section plots (EEDL, EPDL) - * Binding-energy comparison (EADL vs NIST reference) + * Binding-energy comparison (EADL vs EEDL/ENDF reference) * Transition-energy plots (K -> L2, K -> L3, L2-L3 splitting) * HDF5 round-trip validation - * Data-dictionary completeness check (PyEEDL <-> PyEPICS) + * Data-dictionary completeness check (ENDF source vs PyEPICS) * Docstring coverage audit * Physical-constant verification * Overall pass / fail summary @@ -33,7 +33,6 @@ import ast import datetime import glob -import importlib.util import io import os import subprocess @@ -50,7 +49,6 @@ # --------------------------------------------------------------------------- SCRIPT_DIR = Path(__file__).resolve().parent PYEPICS_ROOT = SCRIPT_DIR.parent -PYEEDL_ROOT = PYEPICS_ROOT.parent / "PyEEDL" # Ensure pyepics is importable if str(PYEPICS_ROOT) not in sys.path: @@ -167,8 +165,6 @@ def section_cover(pdf, ctx): ha="center", va="center", color="gray", transform=ax.transAxes) ax.text(0.5, 0.42, f"PyEPICS root: {PYEPICS_ROOT}", fontsize=10, ha="center", va="center", color="gray", transform=ax.transAxes) - ax.text(0.5, 0.36, f"PyEEDL root: {PYEEDL_ROOT}", fontsize=10, - ha="center", va="center", color="gray", transform=ax.transAxes) pdf.savefig(fig) plt.close(fig) return {"passed": True} @@ -206,7 +202,7 @@ def section_eedl_plots(pdf, ctx): M = ctx["M"] _section_title_page(pdf, "2. EEDL Electron Cross-Section Plots") - eedl_dir = PYEEDL_ROOT / "eedl" + eedl_dir = PYEPICS_ROOT / "data" / "endf" / "eedl" eedl_files = sorted(eedl_dir.glob("*EEDL*.endf")) if eedl_dir.exists() else [] if not eedl_files: @@ -214,7 +210,7 @@ def section_eedl_plots(pdf, ctx): "No EEDL ENDF files found.", f"Searched: {eedl_dir}", "", - "Download from: https://www-nds.iaea.org/epics/", + "Download from: https://nuclear.llnl.gov/EPICS/", ], title="EEDL — skipped") return {"passed": None} # skipped @@ -256,7 +252,7 @@ def section_epdl_plots(pdf, ctx): M = ctx["M"] _section_title_page(pdf, "3. EPDL Photon Cross-Section Plots") - epdl_files = sorted((PYEEDL_ROOT / "eedl").glob("*EPDL*.endf")) if (PYEEDL_ROOT / "eedl").exists() else [] + epdl_files = sorted((PYEPICS_ROOT / "data" / "endf" / "epdl").glob("*EPDL*.endf")) if (PYEPICS_ROOT / "data" / "endf" / "epdl").exists() else [] if not epdl_files: _text_page(pdf, ["No EPDL ENDF files found — skipped."], title="EPDL") return {"passed": None} @@ -286,14 +282,18 @@ def section_epdl_plots(pdf, ctx): # ------------------------------------------------------------------- def section_binding_energy(pdf, ctx): - """Compare EADL binding energies against NIST reference data.""" + """Compare EADL binding energies against EEDL/ENDF reference data. + + The reference CSV contains binding energies extracted from the EEDL ENDF + files, not from NIST. Analysis covers all available subshells. + """ import matplotlib.pyplot as plt M = ctx["M"] _section_title_page(pdf, "4. Binding-Energy Validation", - "EADL vs NIST reference (K-shell)") + "EADL vs EEDL/ENDF reference (all subshells)") - mcdc_dir = PYEEDL_ROOT / "mcdc_data" + mcdc_dir = PYEPICS_ROOT / "data" / "mcdc" / "electron" h5_files = sorted(mcdc_dir.glob("*.h5")) if mcdc_dir.exists() else [] if not h5_files: @@ -323,55 +323,103 @@ def section_binding_energy(pdf, ctx): df_be = pd.DataFrame(be_rows) ctx["df_be"] = df_be - # Load NIST reference - ref_csv = PYEPICS_ROOT / "reference_data" / "reference_binding_energies.csv" + # Load EEDL/ENDF reference (not NIST) + ref_csv = PYEPICS_ROOT / "tests" / "fixtures" / "reference_binding_energies.csv" if not ref_csv.exists(): _text_page(pdf, [f"Reference CSV not found: {ref_csv}"], title="Binding energy") return {"passed": None} df_ref = pd.read_csv(ref_csv) - df_ref_k = df_ref[df_ref["subshell"] == "K"].copy() - df_k = df_be[df_be["subshell"] == "K"].sort_values("Z").copy() - # Plot - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8.5), sharex=True, - gridspec_kw={"height_ratios": [3, 1]}) - ax1.semilogy(df_k["Z"], df_k["be_eV"], "o-", color="blue", ms=4, - label="EADL (parsed)", alpha=0.8) - ax1.semilogy(df_ref_k["Z"], df_ref_k["binding_energy_eV"], "s", color="red", - ms=8, label="NIST Reference", zorder=5) - ax1.set_ylabel("K-Shell Binding Energy (eV)") - ax1.set_title("K-Shell Binding Energy: EADL vs NIST Reference") - ax1.legend() - ax1.grid(True, alpha=0.3) + # --- Per-subshell analysis --- + all_subshells = sorted(df_ref["subshell"].unique()) + overall_max_err = 0.0 + subshell_summaries = [] - df_comp = pd.merge(df_k[["Z", "be_eV"]], df_ref_k[["Z", "binding_energy_eV"]], on="Z") - df_comp["rel_error_pct"] = 100 * abs( - df_comp["be_eV"] - df_comp["binding_energy_eV"] - ) / df_comp["binding_energy_eV"] + for shell in all_subshells: + df_ref_shell = df_ref[df_ref["subshell"] == shell].copy() + df_shell = df_be[df_be["subshell"] == shell].sort_values("Z").copy() - ax2.bar(df_comp["Z"], df_comp["rel_error_pct"], color="orange", alpha=0.7, width=1.0) - ax2.axhline(5.0, color="red", ls="--", label="5% threshold") - ax2.set_ylabel("Relative Error (%)") - ax2.set_xlabel("Atomic Number (Z)") - ax2.legend() - ax2.grid(True, alpha=0.3) - fig.tight_layout() - pdf.savefig(fig) - plt.close(fig) + if df_shell.empty or df_ref_shell.empty: + continue + + df_comp = pd.merge( + df_shell[["Z", "be_eV"]], + df_ref_shell[["Z", "binding_energy_eV"]], + on="Z", + ) + if df_comp.empty: + continue + + df_comp["rel_error_pct"] = 100 * abs( + df_comp["be_eV"] - df_comp["binding_energy_eV"] + ) / df_comp["binding_energy_eV"] - max_err = df_comp["rel_error_pct"].max() - ctx["be_max_err"] = max_err + max_err = df_comp["rel_error_pct"].max() + overall_max_err = max(overall_max_err, max_err) + subshell_summaries.append({ + "subshell": shell, + "n_elements": len(df_comp), + "max_err_pct": max_err, + "passed": bool(max_err < 5.0), + }) + # Plot K-shell (primary visual — always present in reference) + df_ref_k = df_ref[df_ref["subshell"] == "K"].copy() + df_k = df_be[df_be["subshell"] == "K"].sort_values("Z").copy() + + if not df_k.empty and not df_ref_k.empty: + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(11, 8.5), sharex=True, + gridspec_kw={"height_ratios": [3, 1]}) + ax1.semilogy(df_k["Z"], df_k["be_eV"], "o-", color="blue", ms=4, + label="EADL (parsed)", alpha=0.8) + ax1.semilogy(df_ref_k["Z"], df_ref_k["binding_energy_eV"], "s", color="red", + ms=8, label="EEDL/ENDF Reference", zorder=5) + ax1.set_ylabel("K-Shell Binding Energy (eV)") + ax1.set_title("K-Shell Binding Energy: EADL vs EEDL/ENDF Reference") + ax1.legend() + ax1.grid(True, alpha=0.3) + + df_comp_k = pd.merge(df_k[["Z", "be_eV"]], df_ref_k[["Z", "binding_energy_eV"]], on="Z") + df_comp_k["rel_error_pct"] = 100 * abs( + df_comp_k["be_eV"] - df_comp_k["binding_energy_eV"] + ) / df_comp_k["binding_energy_eV"] + + ax2.bar(df_comp_k["Z"], df_comp_k["rel_error_pct"], color="orange", alpha=0.7, width=1.0) + ax2.axhline(5.0, color="red", ls="--", label="5% threshold") + ax2.set_ylabel("Relative Error (%)") + ax2.set_xlabel("Atomic Number (Z)") + ax2.legend() + ax2.grid(True, alpha=0.3) + fig.tight_layout() + pdf.savefig(fig) + plt.close(fig) + + ctx["be_max_err"] = overall_max_err + + # Summary page for all subshells lines = [ - f"Compared {len(df_comp)} elements (K-shell)", - f"Max relative error: {max_err:.3f}%", + "Binding-energy validation: EADL (parsed) vs EEDL/ENDF reference", + "Source: tests/fixtures/reference_binding_energies.csv (from EEDL ENDF files)", "", - "PASS" if max_err < 5.0 else "FAIL — some values exceed 5% threshold", + f"{'Subshell':<12} {'Elements':>10} {'Max Error (%)':>14} {'Status':>8}", + "=" * 50, ] - _text_page(pdf, lines, title="Binding-energy numerical summary") + for s in subshell_summaries: + status = "PASS" if s["passed"] else "FAIL" + lines.append( + f"{s['subshell']:<12} {s['n_elements']:>10} " + f"{s['max_err_pct']:>14.3f} {status:>8}" + ) + lines += [ + "", + f"Overall max relative error: {overall_max_err:.3f}%", + "", + "PASS" if overall_max_err < 5.0 else "FAIL — some values exceed 5% threshold", + ] + _text_page(pdf, lines, title="Binding-energy summary (all subshells)") - return {"passed": max_err < 5.0} + return {"passed": bool(overall_max_err < 5.0)} # ------------------------------------------------------------------- @@ -443,7 +491,7 @@ def section_h5_cross_sections(pdf, ctx): M = ctx["M"] _section_title_page(pdf, "6. MC/DC HDF5 Cross-Section Plots") - mcdc_dir = PYEEDL_ROOT / "mcdc_data" + mcdc_dir = PYEPICS_ROOT / "data" / "mcdc" / "electron" h5py = M.h5py PERIODIC_TABLE = M.PERIODIC_TABLE @@ -563,70 +611,107 @@ def section_hdf5_roundtrip(pdf, ctx): # ------------------------------------------------------------------- def section_data_dictionaries(pdf, ctx): - """Compare PyEEDL and PyEPICS data-mapping dictionaries.""" + """Verify PyEPICS data-mapping dictionaries against ENDF file contents. + + Instead of comparing against an external tool, this section verifies + that PyEPICS mapping dictionaries cover all (MF, MT) section pairs + found in the actual ENDF source files. + """ M = ctx["M"] - _section_title_page(pdf, "8. Data-Dictionary Completeness") + _section_title_page(pdf, "8. Data-Dictionary Completeness", + "ENDF source files vs PyEPICS mappings") - pyeedl_data_path = PYEEDL_ROOT / "pyeedl" / "data.py" - if not pyeedl_data_path.exists(): - _text_page(pdf, [f"PyEEDL data.py not found: {pyeedl_data_path}"]) - return {"passed": None} + # Discover (MF, MT) pairs present in actual ENDF files + endf_dir = PYEPICS_ROOT / "data" / "endf" + endf_mf_mt_sets = {"eedl": set(), "epdl": set(), "eadl": set()} - spec = importlib.util.spec_from_file_location("pyeedl_data", str(pyeedl_data_path)) - pyeedl_data = importlib.util.module_from_spec(spec) - spec.loader.exec_module(pyeedl_data) - - dict_pairs = [ - ("MF_MT", getattr(pyeedl_data, "MF_MT", {}), M.MF_MT), - ("SECTIONS_ABBREVS", getattr(pyeedl_data, "SECTIONS_ABBREVS", {}), M.SECTIONS_ABBREVS), - ("PHOTON_MF_MT", getattr(pyeedl_data, "PHOTON_MF_MT", {}), M.PHOTON_MF_MT), - ("PHOTON_SECTIONS_ABBREVS", getattr(pyeedl_data, "PHOTON_SECTIONS_ABBREVS", {}), M.PHOTON_SECTIONS_ABBREVS), - ("ATOMIC_MF_MT", getattr(pyeedl_data, "ATOMIC_MF_MT", {}), M.ATOMIC_MF_MT), - ("SUBSHELL_LABELS", getattr(pyeedl_data, "SUBSHELL_LABELS", {}), M.SUBSHELL_LABELS), - ("SUBSHELL_DESIGNATORS", getattr(pyeedl_data, "SUBSHELL_DESIGNATORS", {}), M.SUBSHELL_DESIGNATORS), - ("PERIODIC_TABLE", getattr(pyeedl_data, "PERIODIC_TABLE", {}), M.PERIODIC_TABLE), - ("MF23", getattr(pyeedl_data, "MF23", {}), M.MF23), - ("MF26", getattr(pyeedl_data, "MF26", {}), M.MF26), - ("MF27", getattr(pyeedl_data, "MF27", {}), M.MF27), - ("MF28", getattr(pyeedl_data, "MF28", {}), M.MF28), + for lib_name in ("eedl", "epdl", "eadl"): + lib_dir = endf_dir / lib_name + if not lib_dir.exists(): + continue + for fpath in sorted(lib_dir.glob("*.endf"))[:5]: # sample first 5 + try: + import endf + tape = endf.Material(fpath) + # section_data keys are already (MF, MT) tuples + for mf_mt in tape.section_data: + endf_mf_mt_sets[lib_name].add(mf_mt) + except Exception: + pass + + # Compare PyEPICS mapping tables against ENDF contents + mapping_checks = [ + ("MF_MT (EEDL)", endf_mf_mt_sets.get("eedl", set()), M.MF_MT), + ("PHOTON_MF_MT (EPDL)", endf_mf_mt_sets.get("epdl", set()), M.PHOTON_MF_MT), + ("ATOMIC_MF_MT (EADL)", endf_mf_mt_sets.get("eadl", set()), M.ATOMIC_MF_MT), ] lines = [ - f"{'Dictionary':<28} {'PyEEDL':>7} {'PyEPICS':>8} {'Match':>6} {'Miss':>5} {'Extra':>6} Status", - "=" * 80, + f"{'Mapping':<28} {'ENDF':>6} {'PyEPICS':>8} {'Covered':>8} {'Missing':>8} Status", + "=" * 72, ] all_ok = True - for name, old_d, new_d in dict_pairs: - old_k, new_k = set(old_d.keys()), set(new_d.keys()) - miss = len(old_k - new_k) - extra = len(new_k - old_k) - match = len(old_k & new_k) - status = "OK" if miss == 0 else f"MISSING {miss}" - if miss > 0: + for name, endf_keys, pyepics_dict in mapping_checks: + pyepics_keys = set(pyepics_dict.keys()) + covered = len(endf_keys & pyepics_keys) + missing = len(endf_keys - pyepics_keys) + status = "OK" if missing == 0 else f"MISSING {missing}" + if missing > 0: all_ok = False lines.append( - f"{name:<28} {len(old_k):>7} {len(new_k):>8} {match:>6} {miss:>5} {extra:>6} {status}" + f"{name:<28} {len(endf_keys):>6} {len(pyepics_keys):>8} " + f"{covered:>8} {missing:>8} {status}" ) - _text_page(pdf, lines, title="Data-dictionary comparison") + if not any(endf_mf_mt_sets.values()): + lines.append("") + lines.append("No ENDF files found — dictionary check inconclusive.") + lines.append("Download ENDF data with: python -m pyepics.cli download") + _text_page(pdf, lines, title="Data-dictionary comparison (ENDF vs PyEPICS)") + return {"passed": None} + + _text_page(pdf, lines, title="Data-dictionary comparison (ENDF vs PyEPICS)") - # Physical constants - const_names = [ - "FINE_STRUCTURE", "ELECTRON_MASS", "BARN_TO_CM2", - "PLANCK_CONSTANT", "SPEED_OF_LIGHT", "ELECTRON_CHARGE", + # Verify internal dictionary consistency + internal_checks = [ + ("PERIODIC_TABLE", M.PERIODIC_TABLE, 100), # Z=1..100 + ("SUBSHELL_LABELS", M.SUBSHELL_LABELS, 1), + ("SUBSHELL_DESIGNATORS", M.SUBSHELL_DESIGNATORS, 1), + ("SECTIONS_ABBREVS", M.SECTIONS_ABBREVS, 1), + ] + + int_lines = [ + f"{'Dictionary':<28} {'Entries':>8} {'Min expected':>13} Status", + "=" * 56, + ] + for name, d, min_expected in internal_checks: + n = len(d) + ok = n >= min_expected + if not ok: + all_ok = False + int_lines.append(f"{name:<28} {n:>8} {min_expected:>13} {'OK' if ok else 'FAIL'}") + + _text_page(pdf, int_lines, title="Internal dictionary completeness") + + # Physical constants (self-check against NIST CODATA 2018 values) + const_checks = [ + ("FINE_STRUCTURE", M.FINE_STRUCTURE, 7.2973525693e-3), + ("ELECTRON_MASS", M.ELECTRON_MASS, 0.51099895069), + ("BARN_TO_CM2", M.BARN_TO_CM2, 1e-24), + ("PLANCK_CONSTANT", M.PLANCK_CONSTANT, 6.62607015e-34), + ("SPEED_OF_LIGHT", M.SPEED_OF_LIGHT, 299792458.0), + ("ELECTRON_CHARGE", M.ELECTRON_CHARGE, 1.602176634e-19), ] - const_lines = [f"{'Constant':<25} {'PyEEDL':>20} {'PyEPICS':>20} Match", "=" * 72] + const_lines = [f"{'Constant':<25} {'PyEPICS':>20} {'CODATA 2018':>20} Match", "=" * 72] const_ok = True - for name in const_names: - old_v = getattr(pyeedl_data, name, None) - new_v = getattr(M, name, None) - ok = old_v == new_v if (old_v is not None and new_v is not None) else False + for name, pyepics_v, ref_v in const_checks: + ok = pyepics_v == ref_v if not ok: const_ok = False - const_lines.append(f"{name:<25} {str(old_v):>20} {str(new_v):>20} {'OK' if ok else 'MISMATCH'}") + const_lines.append(f"{name:<25} {str(pyepics_v):>20} {str(ref_v):>20} {'OK' if ok else 'MISMATCH'}") - const_lines += ["", "All constants match." if const_ok else "MISMATCH detected!"] - _text_page(pdf, const_lines, title="Physical-constant verification") + const_lines += ["", "All constants match CODATA 2018." if const_ok else "MISMATCH detected!"] + _text_page(pdf, const_lines, title="Physical-constant verification (NIST CODATA 2018)") ctx["const_ok"] = const_ok return {"passed": all_ok and const_ok} @@ -770,7 +855,8 @@ def generate_report(output_path: str | Path) -> bool: section_summary(pdf, ctx) all_pass = all( - r["passed"] is not False for r in ctx["results"].values() + r["passed"] is None or r["passed"] # None = skipped, truthy = passed + for r in ctx["results"].values() ) print() print(f"Report written to: {output_path}") @@ -782,7 +868,7 @@ def main(): parser = argparse.ArgumentParser( description="Generate PyEPICS regression-test PDF report.", ) - default_out = PYEPICS_ROOT / "reports" / "regression_report.pdf" + default_out = PYEPICS_ROOT / "tests" / "reports" / "regression_report.pdf" parser.add_argument( "-o", "--output", default=str(default_out), diff --git a/tests/regression_tests.ipynb b/tests/regression_tests.ipynb index 6815262..8d7f202 100644 --- a/tests/regression_tests.ipynb +++ b/tests/regression_tests.ipynb @@ -10,8 +10,8 @@ "This notebook performs the following:\n", "1. Discovers and runs existing **unit tests**\n", "2. Parses EEDL / EPDL / EADL files and produces **cross-section plots**\n", - "3. Compares results against **NIST / EPICS 2023 reference data**\n", - "4. Verifies that **data dictionaries** from PyEEDL are present in PyEPICS\n", + "3. Compares results against **EEDL/ENDF reference data**\n", + "4. Verifies that **data dictionaries** cover all ENDF (MF, MT) sections\n", "\n", "> **Tip:** Run `python tests/generate_report.py` to produce a self-contained PDF report\n", "> with all analyses, plots, and pass/fail summaries.\n", @@ -32,17 +32,7 @@ "execution_count": null, "id": "11fdcc62", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "PyEPICS root: /Users/melekderman/github/Summer25/3_MCDC-Electron/DrPrinja/PyEPICS\n", - "PyEEDL root: /Users/melekderman/github/Summer25/3_MCDC-Electron/DrPrinja/PyEEDL\n", - "Setup OK ✓\n" - ] - } - ], + "outputs": [], "source": [ "import sys, os, glob, ast, inspect, importlib\n", "import numpy as np\n", @@ -54,9 +44,6 @@ "if PYEPICS_ROOT not in sys.path:\n", " sys.path.insert(0, PYEPICS_ROOT)\n", "\n", - "# Add PyEEDL to path (for comparison)\n", - "PYEEDL_ROOT = os.path.abspath(os.path.join(PYEPICS_ROOT, \"..\", \"PyEEDL\"))\n", - "\n", "from pyepics.readers.eedl import EEDLReader\n", "from pyepics.readers.epdl import EPDLReader\n", "from pyepics.readers.eadl import EADLReader\n", @@ -77,7 +64,6 @@ "})\n", "\n", "print(f\"PyEPICS root: {PYEPICS_ROOT}\")\n", - "print(f\"PyEEDL root: {PYEEDL_ROOT}\")\n", "print(\"Setup OK\")" ] }, @@ -88,7 +74,7 @@ "source": [ "## 2. Discover & List Existing Unit Tests\n", "\n", - "Scans both repositories for `test_*.py` files and lists test classes and functions." + "Scans the PyEPICS repository for `test_*.py` files and lists test classes and functions." ] }, { @@ -96,135 +82,7 @@ "execution_count": null, "id": "05851395", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "PyEPICS test fonksiyonu: 74\n", - "PyEEDL test fonksiyonu: 0\n", - "\n" - ] - }, - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
fileclassfunction
0tests/test_eadl.pyTestEADLDatasettest_atomic_number
1tests/test_eadl.pyTestEADLDatasettest_symbol
2tests/test_eadl.pyTestEADLDatasettest_subshell_count
3tests/test_eadl.pyTestEADLDatasettest_k_shell_exists
4tests/test_eadl.pyTestEADLDatasettest_k_binding_energy
............
69tests/test_hdf5.pyTestWriteEADLtest_binding_energy_value
70tests/test_hdf5.pyTestWriteEADLtest_radiative_transitions
71tests/test_hdf5.pyTestWriteEADLtest_summary_arrays
72tests/test_hdf5.pyTestConvertErrorstest_invalid_dataset_type
73tests/test_hdf5.pyTestConvertErrorstest_overwrite_false_existing_file
\n", - "

74 rows × 3 columns

\n", - "
" - ], - "text/plain": [ - " file class function\n", - "0 tests/test_eadl.py TestEADLDataset test_atomic_number\n", - "1 tests/test_eadl.py TestEADLDataset test_symbol\n", - "2 tests/test_eadl.py TestEADLDataset test_subshell_count\n", - "3 tests/test_eadl.py TestEADLDataset test_k_shell_exists\n", - "4 tests/test_eadl.py TestEADLDataset test_k_binding_energy\n", - ".. ... ... ...\n", - "69 tests/test_hdf5.py TestWriteEADL test_binding_energy_value\n", - "70 tests/test_hdf5.py TestWriteEADL test_radiative_transitions\n", - "71 tests/test_hdf5.py TestWriteEADL test_summary_arrays\n", - "72 tests/test_hdf5.py TestConvertErrors test_invalid_dataset_type\n", - "73 tests/test_hdf5.py TestConvertErrors test_overwrite_false_existing_file\n", - "\n", - "[74 rows x 3 columns]" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "def discover_tests(root_dir, label):\n", " \"\"\"Scan test files via AST and extract class/function names.\"\"\"\n", @@ -249,11 +107,9 @@ " return rows\n", "\n", "rows_epics = discover_tests(PYEPICS_ROOT, \"PyEPICS\")\n", - "rows_eedl = discover_tests(PYEEDL_ROOT, \"PyEEDL\")\n", "\n", - "df_tests = pd.DataFrame(rows_epics + rows_eedl)\n", + "df_tests = pd.DataFrame(rows_epics)\n", "print(f\"PyEPICS test functions: {len(rows_epics)}\")\n", - "print(f\"PyEEDL test functions: {len(rows_eedl)}\")\n", "print()\n", "\n", "# Display PyEPICS tests as a table\n", @@ -330,28 +186,17 @@ "execution_count": null, "id": "efb2d0e0", "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "EEDL dosyaları bulundu: 0\n", - "⚠ EEDL ENDF dosyaları bulunamadı.\n", - " Lütfen PyEEDL/download_data.py veya IAEA'dan indirin:\n", - " https://www-nds.iaea.org/epics/\n" - ] - } - ], + "outputs": [], "source": [ - "# Locate EEDL files\n", - "eedl_dir = os.path.join(PYEEDL_ROOT, \"eedl\")\n", + "# Locate EEDL files from PyEPICS data directory\n", + "eedl_dir = os.path.join(PYEPICS_ROOT, \"data\", \"endf\", \"eedl\")\n", "eedl_files = sorted(glob.glob(os.path.join(eedl_dir, \"*.endf\")))\n", "print(f\"EEDL files found: {len(eedl_files)}\")\n", "\n", "if not eedl_files:\n", " print(\"WARNING: No EEDL ENDF files found.\")\n", - " print(\" Please download via PyEEDL/download_data.py or from IAEA:\")\n", - " print(\" https://www-nds.iaea.org/epics/\")\n", + " print(\" Download with: python -m pyepics.cli download\")\n", + " print(\" Or from: https://nuclear.llnl.gov/EPICS/\")\n", "else:\n", " for f in eedl_files[:5]:\n", " print(f\" {os.path.basename(f)}\")" @@ -434,11 +279,9 @@ "metadata": {}, "outputs": [], "source": [ - "# Locate EPDL files\n", - "epdl_dir = os.path.join(PYEEDL_ROOT, \"eedl\")\n", + "# Locate EPDL files from PyEPICS data directory\n", + "epdl_dir = os.path.join(PYEPICS_ROOT, \"data\", \"endf\", \"epdl\")\n", "epdl_files = sorted(glob.glob(os.path.join(epdl_dir, \"*EPDL*\")))\n", - "if not epdl_files:\n", - " epdl_files = sorted(glob.glob(os.path.join(PYEEDL_ROOT, \"**\", \"*EPDL*\"), recursive=True))\n", "\n", "print(f\"EPDL files found: {len(epdl_files)}\")\n", "\n", @@ -479,9 +322,10 @@ "source": [ "## 5. Comparison Against EPICS Reference Data\n", "\n", - "### 5a. K-Shell Binding Energy: EADL vs NIST Reference\n", + "### 5a. Binding Energy: EADL vs EEDL/ENDF Reference (All Subshells)\n", "\n", - "Compares K-shell binding energies parsed from EADL against NIST reference values.\n", + "Compares binding energies parsed from EADL against EEDL/ENDF reference values.\n", + "The reference CSV is extracted from EEDL ENDF files (not NIST).\n", "Produces both an overlay plot and a relative-error bar chart." ] }, @@ -492,15 +336,8 @@ "metadata": {}, "outputs": [], "source": [ - "# --- Extract binding energies from EADL / MC/DC HDF5 files ---\n", - "eadl_files = sorted(glob.glob(os.path.join(PYEEDL_ROOT, \"eedl\", \"*EADL*\")))\n", - "if not eadl_files:\n", - " eadl_files = sorted(glob.glob(os.path.join(PYEEDL_ROOT, \"**\", \"*EADL*\"), recursive=True))\n", - "\n", - "print(f\"EADL files found: {len(eadl_files)}\")\n", - "\n", - "# Alternative: read binding energies from MC/DC HDF5 files\n", - "mcdc_dir = os.path.join(PYEEDL_ROOT, \"mcdc_data\")\n", + "# --- Extract binding energies from PyEPICS MC/DC HDF5 files ---\n", + "mcdc_dir = os.path.join(PYEPICS_ROOT, \"data\", \"mcdc\", \"electron\")\n", "h5_files = sorted(glob.glob(os.path.join(mcdc_dir, \"*.h5\")))\n", "print(f\"MC/DC HDF5 files: {len(h5_files)}\")\n", "\n", @@ -531,28 +368,56 @@ "metadata": {}, "outputs": [], "source": [ - "# --- Load NIST reference data ---\n", - "ref_csv = os.path.join(PYEPICS_ROOT, \"reference_data\", \"reference_binding_energies.csv\")\n", + "# --- Load EEDL/ENDF reference data (not NIST) ---\n", + "ref_csv = os.path.join(PYEPICS_ROOT, \"tests\", \"fixtures\", \"reference_binding_energies.csv\")\n", "df_ref = pd.read_csv(ref_csv)\n", - "df_ref_k = df_ref[df_ref[\"subshell\"] == \"K\"].copy()\n", "\n", - "# EADL K-shell data\n", + "# Analyse all available subshells\n", + "all_subshells = sorted(df_ref[\"subshell\"].unique())\n", + "print(f\"Reference subshells: {all_subshells}\")\n", + "\n", + "overall_max_err = 0.0\n", + "subshell_summaries = []\n", + "\n", + "for shell in all_subshells:\n", + " df_ref_shell = df_ref[df_ref[\"subshell\"] == shell].copy()\n", + " df_shell = df_be[df_be[\"subshell\"] == shell].sort_values(\"Z\").copy()\n", + " if df_shell.empty or df_ref_shell.empty:\n", + " continue\n", + " df_comp_shell = pd.merge(df_shell[[\"Z\", \"be_eV\"]], df_ref_shell[[\"Z\", \"binding_energy_eV\"]], on=\"Z\")\n", + " if df_comp_shell.empty:\n", + " continue\n", + " df_comp_shell[\"rel_error_pct\"] = 100 * abs(\n", + " df_comp_shell[\"be_eV\"] - df_comp_shell[\"binding_energy_eV\"]\n", + " ) / df_comp_shell[\"binding_energy_eV\"]\n", + " max_err = df_comp_shell[\"rel_error_pct\"].max()\n", + " overall_max_err = max(overall_max_err, max_err)\n", + " subshell_summaries.append({\n", + " \"Subshell\": shell, \"Elements\": len(df_comp_shell),\n", + " \"Max Error (%)\": f\"{max_err:.3f}\",\n", + " \"Status\": \"PASS\" if max_err < 5.0 else \"FAIL\",\n", + " })\n", + "\n", + "# Display per-subshell summary\n", + "df_summary = pd.DataFrame(subshell_summaries)\n", + "display(df_summary)\n", + "\n", + "# K-shell plot (primary visual)\n", + "df_ref_k = df_ref[df_ref[\"subshell\"] == \"K\"].copy()\n", "df_k = df_be[df_be[\"subshell\"] == \"K\"].sort_values(\"Z\").copy()\n", "\n", - "# --- Comparison plot ---\n", "fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), sharex=True,\n", " gridspec_kw={\"height_ratios\": [3, 1]})\n", "\n", "ax1.semilogy(df_k[\"Z\"], df_k[\"be_eV\"], \"o-\", color=\"blue\", markersize=4,\n", " label=\"EADL (parsed)\", alpha=0.8)\n", "ax1.semilogy(df_ref_k[\"Z\"], df_ref_k[\"binding_energy_eV\"], \"s\", color=\"red\",\n", - " markersize=8, label=\"NIST Reference\", zorder=5)\n", + " markersize=8, label=\"EEDL/ENDF Reference\", zorder=5)\n", "ax1.set_ylabel(\"K-Shell Binding Energy (eV)\")\n", - "ax1.set_title(\"K-Shell Binding Energy: EADL vs NIST Reference\")\n", + "ax1.set_title(\"K-Shell Binding Energy: EADL vs EEDL/ENDF Reference\")\n", "ax1.legend()\n", "ax1.grid(True, alpha=0.3)\n", "\n", - "# Compute relative error (merge on Z)\n", "df_comp = pd.merge(df_k[[\"Z\", \"be_eV\"]], df_ref_k[[\"Z\", \"binding_energy_eV\"]], on=\"Z\")\n", "df_comp[\"rel_error_pct\"] = 100 * abs(df_comp[\"be_eV\"] - df_comp[\"binding_energy_eV\"]) / df_comp[\"binding_energy_eV\"]\n", "\n", @@ -566,12 +431,11 @@ "plt.tight_layout()\n", "plt.show()\n", "\n", - "max_err = df_comp[\"rel_error_pct\"].max()\n", - "print(f\"Max relative error: {max_err:.2f}%\")\n", - "if max_err < 5.0:\n", - " print(\"PASS: All K-shell binding energies within 5% of NIST values\")\n", + "print(f\"\\nOverall max relative error (all subshells): {overall_max_err:.3f}%\")\n", + "if overall_max_err < 5.0:\n", + " print(\"PASS: All binding energies within 5% of EEDL/ENDF reference values\")\n", "else:\n", - " print(f\"FAIL: Some values exceed 5% threshold!\")" + " print(\"FAIL: Some values exceed 5% threshold!\")" ] }, { @@ -641,7 +505,7 @@ "source": [ "### 5c. HDF5 Cross-Section Plots (from MC/DC HDF5 files)\n", "\n", - "Reads cross sections from PyEEDL's MC/DC HDF5 output files and produces plots in the same style as PyEEDL's `test_be.ipynb`." + "Reads cross sections from PyEPICS MC/DC HDF5 output files and produces electron cross-section plots." ] }, { @@ -709,9 +573,9 @@ "id": "5ed5c207", "metadata": {}, "source": [ - "## 6. PyEEDL Data Structure Inspection: Descriptions & Metadata\n", + "## 6. Data Structure Inspection: Constants & Mappings\n", "\n", - "Inspects the data dictionaries (MF23, MF26, MF27, MF28, MF_MT, etc.) from PyEEDL's `data.py` and compares them with PyEPICS's `constants.py`." + "Inspects the data dictionaries (MF23, MF26, MF27, MF28, MF_MT, etc.) in PyEPICS's `constants.py` and verifies their completeness." ] }, { @@ -721,30 +585,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Exported symbols from PyEEDL data.py\n", - "pyeedl_data_path = os.path.join(PYEEDL_ROOT, \"pyeedl\", \"data.py\")\n", - "pyeedl_exports = []\n", - "if os.path.exists(pyeedl_data_path):\n", - " tree = ast.parse(open(pyeedl_data_path).read())\n", - " for node in ast.walk(tree):\n", - " if isinstance(node, ast.Assign):\n", - " for target in node.targets:\n", - " if isinstance(target, ast.Name) and target.id.isupper():\n", - " pyeedl_exports.append(target.id)\n", - " print(\"PyEEDL data.py exports:\")\n", - " for name in sorted(set(pyeedl_exports)):\n", - " print(f\" - {name}\")\n", - "else:\n", - " print(\"PyEEDL data.py not found\")\n", - "\n", - "print()\n", - "\n", "# Exported symbols from PyEPICS constants.py\n", "from pyepics.utils import constants as epics_const\n", "pyepics_exports = [n for n in dir(epics_const) if n.isupper() and not n.startswith(\"_\")]\n", "print(\"PyEPICS constants.py exports:\")\n", "for name in sorted(pyepics_exports):\n", - " print(f\" - {name}\")" + " print(f\" - {name}\")\n", + "\n", + "print(f\"\\nTotal exported constants/dictionaries: {len(pyepics_exports)}\")" ] }, { @@ -752,9 +600,9 @@ "id": "d62cdeb7", "metadata": {}, "source": [ - "## 7. PyEPICS Data Descriptions & Documentation Check\n", + "## 7. Data Dictionary Internal Consistency Check\n", "\n", - "Compares data dictionaries (MF_MT, PERIODIC_TABLE, MF23, MF26, etc.) side by side and detects missing keys." + "Verifies that PyEPICS data dictionaries (MF_MT, PERIODIC_TABLE, MF23, MF26, etc.) have the expected minimum number of entries and checks for completeness." ] }, { @@ -764,49 +612,38 @@ "metadata": {}, "outputs": [], "source": [ - "# --- Value comparison: MF_MT dictionaries ---\n", - "# Import PyEEDL's data.py\n", - "pyeedl_data_spec = importlib.util.spec_from_file_location(\"pyeedl_data\", pyeedl_data_path)\n", - "pyeedl_data = importlib.util.module_from_spec(pyeedl_data_spec)\n", - "pyeedl_data_spec.loader.exec_module(pyeedl_data)\n", - "\n", - "comparison_rows = []\n", - "\n", - "dict_pairs = [\n", - " (\"MF_MT\", getattr(pyeedl_data, \"MF_MT\", {}), MF_MT),\n", - " (\"SECTIONS_ABBREVS\", getattr(pyeedl_data, \"SECTIONS_ABBREVS\", {}), SECTIONS_ABBREVS),\n", - " (\"PHOTON_MF_MT\", getattr(pyeedl_data, \"PHOTON_MF_MT\", {}), PHOTON_MF_MT),\n", - " (\"PHOTON_SECTIONS_ABBREVS\", getattr(pyeedl_data, \"PHOTON_SECTIONS_ABBREVS\", {}), PHOTON_SECTIONS_ABBREVS),\n", - " (\"ATOMIC_MF_MT\", getattr(pyeedl_data, \"ATOMIC_MF_MT\", {}), ATOMIC_MF_MT),\n", - " (\"SUBSHELL_LABELS\", getattr(pyeedl_data, \"SUBSHELL_LABELS\", {}), SUBSHELL_LABELS),\n", - " (\"SUBSHELL_DESIGNATORS\", getattr(pyeedl_data, \"SUBSHELL_DESIGNATORS\", {}), SUBSHELL_DESIGNATORS),\n", - " (\"PERIODIC_TABLE\", getattr(pyeedl_data, \"PERIODIC_TABLE\", {}), PERIODIC_TABLE),\n", - " (\"MF23\", getattr(pyeedl_data, \"MF23\", {}), MF23),\n", - " (\"MF26\", getattr(pyeedl_data, \"MF26\", {}), MF26),\n", - " (\"MF27\", getattr(pyeedl_data, \"MF27\", {}), MF27),\n", - " (\"MF28\", getattr(pyeedl_data, \"MF28\", {}), MF28),\n", + "# --- Internal consistency: verify all dictionaries have expected entries ---\n", + "internal_checks = [\n", + " (\"MF_MT\", MF_MT, 6),\n", + " (\"SECTIONS_ABBREVS\", SECTIONS_ABBREVS, 6),\n", + " (\"PHOTON_MF_MT\", PHOTON_MF_MT, 1),\n", + " (\"PHOTON_SECTIONS_ABBREVS\", PHOTON_SECTIONS_ABBREVS, 1),\n", + " (\"ATOMIC_MF_MT\", ATOMIC_MF_MT, 1),\n", + " (\"SUBSHELL_LABELS\", SUBSHELL_LABELS, 1),\n", + " (\"SUBSHELL_DESIGNATORS\", SUBSHELL_DESIGNATORS, 1),\n", + " (\"PERIODIC_TABLE\", PERIODIC_TABLE, 100),\n", + " (\"MF23\", MF23, 1),\n", + " (\"MF26\", MF26, 1),\n", + " (\"MF27\", MF27, 1),\n", + " (\"MF28\", MF28, 1),\n", "]\n", "\n", - "for name, old_dict, new_dict in dict_pairs:\n", - " old_keys = set(old_dict.keys())\n", - " new_keys = set(new_dict.keys())\n", - " missing = old_keys - new_keys\n", - " extra = new_keys - old_keys\n", - " matching = old_keys & new_keys\n", - " \n", - " status = \"Full match\" if not missing else f\"Missing: {len(missing)} keys\"\n", + "comparison_rows = []\n", + "for name, d, min_expected in internal_checks:\n", + " n = len(d)\n", + " status = \"OK\" if n >= min_expected else f\"FAIL (expected >= {min_expected})\"\n", " comparison_rows.append({\n", " \"Dictionary\": name,\n", - " \"PyEEDL keys\": len(old_keys),\n", - " \"PyEPICS keys\": len(new_keys),\n", - " \"Matching\": len(matching),\n", - " \"Missing\": len(missing),\n", - " \"Extra\": len(extra),\n", + " \"Entries\": n,\n", + " \"Min Expected\": min_expected,\n", " \"Status\": status,\n", " })\n", "\n", "df_comp_dicts = pd.DataFrame(comparison_rows)\n", - "display(df_comp_dicts)" + "display(df_comp_dicts)\n", + "\n", + "all_ok = all(r[\"Status\"] == \"OK\" for r in comparison_rows)\n", + "print(f\"\\n{'All dictionaries have sufficient entries.' if all_ok else 'Some dictionaries are incomplete!'}\")" ] }, { @@ -889,9 +726,9 @@ "id": "a85ca6ac", "metadata": {}, "source": [ - "## 9. Physical Constants Comparison\n", + "## 9. Physical Constants Verification\n", "\n", - "Verifies that physical constants in both packages are identical." + "Verifies that physical constants in PyEPICS match NIST CODATA 2018 reference values." ] }, { @@ -901,20 +738,24 @@ "metadata": {}, "outputs": [], "source": [ - "const_names = [\n", - " \"FINE_STRUCTURE\", \"ELECTRON_MASS\", \"BARN_TO_CM2\",\n", - " \"PLANCK_CONSTANT\", \"SPEED_OF_LIGHT\", \"ELECTRON_CHARGE\",\n", - "]\n", + "# Verify PyEPICS constants against NIST CODATA 2018 reference values\n", + "codata_reference = {\n", + " \"FINE_STRUCTURE\": 7.2973525693e-3,\n", + " \"ELECTRON_MASS\": 0.51099895069,\n", + " \"BARN_TO_CM2\": 1e-24,\n", + " \"PLANCK_CONSTANT\": 6.62607015e-34,\n", + " \"SPEED_OF_LIGHT\": 299792458.0,\n", + " \"ELECTRON_CHARGE\": 1.602176634e-19,\n", + "}\n", "\n", "const_rows = []\n", - "for name in const_names:\n", - " old_val = getattr(pyeedl_data, name, None)\n", - " new_val = getattr(epics_const, name, None)\n", - " match = old_val == new_val if (old_val is not None and new_val is not None) else False\n", + "for name, ref_val in codata_reference.items():\n", + " pyepics_val = getattr(epics_const, name, None)\n", + " match = pyepics_val == ref_val if pyepics_val is not None else False\n", " const_rows.append({\n", " \"Constant\": name,\n", - " \"PyEEDL\": old_val,\n", - " \"PyEPICS\": new_val,\n", + " \"PyEPICS\": pyepics_val,\n", + " \"CODATA 2018\": ref_val,\n", " \"Match\": \"OK\" if match else \"MISMATCH\",\n", " })\n", "\n", @@ -922,7 +763,7 @@ "display(df_const)\n", "\n", "all_match = all(r[\"Match\"] == \"OK\" for r in const_rows)\n", - "print(f\"\\n{'All physical constants match!' if all_match else 'MISMATCH: some constants differ!'}\")" + "print(f\"\\n{'All physical constants match CODATA 2018!' if all_match else 'MISMATCH: some constants differ!'}\")" ] }, { diff --git a/tests/test_eadl.py b/tests/test_eadl.py index 65e4333..ef70b29 100644 --- a/tests/test_eadl.py +++ b/tests/test_eadl.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/tests/test_eedl.py b/tests/test_eedl.py index fa67626..ff55f63 100644 --- a/tests/test_eedl.py +++ b/tests/test_eedl.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/tests/test_epdl.py b/tests/test_epdl.py index 05841b5..b49d9c8 100644 --- a/tests/test_epdl.py +++ b/tests/test_epdl.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/tests/test_hdf5.py b/tests/test_hdf5.py index b41873d..a0d6e4a 100644 --- a/tests/test_hdf5.py +++ b/tests/test_hdf5.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ diff --git a/tests/test_mapping_completeness.py b/tests/test_mapping_completeness.py new file mode 100644 index 0000000..568d346 --- /dev/null +++ b/tests/test_mapping_completeness.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +# ----------------------------------------------------------------------------- +# Copyright (c) 2026 Melek Derman +# +# SPDX-License-Identifier: BSD-3-Clause +# ----------------------------------------------------------------------------- + +""" +Tests for data-dictionary / mapping-table completeness + +Ensures that every (MF, MT) pair found in the shipped ENDF data files +is present in the corresponding PyEPICS mapping dictionary. This +prevents silent data loss when new evaluations are added. + +References +---------- +ENDF-6 Formats Manual (BNL-90365-2009-Rev.2), §0.2 and Appendix B, +describe the MF/MT numbering scheme used throughout EEDL, EPDL, and +EADL evaluated libraries. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from pyepics.utils.constants import ( + ATOMIC_MF_MT, + ATOMIC_SECTIONS_ABBREVS, + MF_MT, + PHOTON_MF_MT, + PHOTON_SECTIONS_ABBREVS, + SECTIONS_ABBREVS, + SUBSHELL_LABELS, + SUBSHELL_DESIGNATORS, + PERIODIC_TABLE, +) + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +PYEPICS_ROOT = Path(__file__).resolve().parent.parent +ENDF_DIR = PYEPICS_ROOT / "data" / "endf" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _collect_endf_mf_mt(lib_name: str) -> set[tuple[int, int]]: + """Return the set of (MF, MT) pairs present in ENDF files for *lib_name*. + + Scans *all* ``.endf`` files under ``data/endf//``. + Requires the ``endf`` package (``pip install endf``). + """ + endf = pytest.importorskip("endf", reason="endf package required") + lib_dir = ENDF_DIR / lib_name + if not lib_dir.exists(): + pytest.skip(f"ENDF directory {lib_dir} not found") + pairs: set[tuple[int, int]] = set() + for fpath in sorted(lib_dir.glob("*.endf")): + tape = endf.Material(fpath) + # section_data keys are already (MF, MT) integer tuples + for mf_mt in tape.section_data: + pairs.add(mf_mt) + if not pairs: + pytest.skip(f"No .endf files found in {lib_dir}") + return pairs + + +# --------------------------------------------------------------------------- +# Parametrised completeness tests +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "lib_name, desc_dict, abbrev_dict", + [ + ("eedl", MF_MT, SECTIONS_ABBREVS), + ("epdl", PHOTON_MF_MT, PHOTON_SECTIONS_ABBREVS), + ("eadl", ATOMIC_MF_MT, ATOMIC_SECTIONS_ABBREVS), + ], + ids=["EEDL", "EPDL", "EADL"], +) +class TestMappingCompleteness: + """All (MF, MT) pairs in ENDF files must be present in mappings.""" + + def test_description_dict_covers_endf(self, lib_name, desc_dict, abbrev_dict): + """Every ENDF (MF, MT) pair must have a human-readable description.""" + endf_pairs = _collect_endf_mf_mt(lib_name) + missing = sorted(endf_pairs - set(desc_dict.keys())) + assert not missing, ( + f"{lib_name.upper()} description dict is missing (MF, MT) pairs: " + f"{missing}. Add them to the corresponding *_MF_MT dict in " + f"pyepics/utils/constants.py." + ) + + def test_abbreviation_dict_covers_endf(self, lib_name, desc_dict, abbrev_dict): + """Every ENDF (MF, MT) pair must have a short abbreviation.""" + endf_pairs = _collect_endf_mf_mt(lib_name) + missing = sorted(endf_pairs - set(abbrev_dict.keys())) + assert not missing, ( + f"{lib_name.upper()} abbreviation dict is missing (MF, MT) pairs: " + f"{missing}. Add them to the corresponding *_SECTIONS_ABBREVS dict " + f"in pyepics/utils/constants.py." + ) + + def test_desc_and_abbrev_keys_match(self, lib_name, desc_dict, abbrev_dict): + """Description and abbreviation dicts must have identical key sets.""" + desc_keys = set(desc_dict.keys()) + abbr_keys = set(abbrev_dict.keys()) + only_desc = sorted(desc_keys - abbr_keys) + only_abbr = sorted(abbr_keys - desc_keys) + assert not only_desc, ( + f"{lib_name.upper()}: keys in description dict but not abbreviation: " + f"{only_desc}" + ) + assert not only_abbr, ( + f"{lib_name.upper()}: keys in abbreviation dict but not description: " + f"{only_abbr}" + ) + + +# --------------------------------------------------------------------------- +# Internal-consistency tests (always run, no ENDF files required) +# --------------------------------------------------------------------------- + +class TestInternalConsistency: + """Checks that mapping dicts are internally well-formed.""" + + def test_periodic_table_has_all_elements(self): + assert len(PERIODIC_TABLE) >= 118 + + def test_subshell_labels_non_empty(self): + assert len(SUBSHELL_LABELS) >= 1 + + def test_subshell_designators_non_empty(self): + assert len(SUBSHELL_DESIGNATORS) >= 1 + + def test_sections_abbrevs_non_empty(self): + assert len(SECTIONS_ABBREVS) >= 1 + + def test_no_duplicate_abbreviations_eedl(self): + """No two EEDL sections share the same abbreviation.""" + vals = list(SECTIONS_ABBREVS.values()) + assert len(vals) == len(set(vals)), "Duplicate abbreviations in SECTIONS_ABBREVS" + + def test_no_duplicate_abbreviations_epdl(self): + """No two EPDL sections share the same abbreviation.""" + vals = list(PHOTON_SECTIONS_ABBREVS.values()) + assert len(vals) == len(set(vals)), "Duplicate abbreviations in PHOTON_SECTIONS_ABBREVS" + + def test_no_duplicate_abbreviations_eadl(self): + """No two EADL sections share the same abbreviation.""" + vals = list(ATOMIC_SECTIONS_ABBREVS.values()) + assert len(vals) == len(set(vals)), "Duplicate abbreviations in ATOMIC_SECTIONS_ABBREVS" diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index f6ccc4d..a8e4687 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -2,7 +2,7 @@ # ----------------------------------------------------------------------------- # Copyright (c) 2026 Melek Derman # -# SPDX-License-Identifier: MIT +# SPDX-License-Identifier: BSD-3-Clause # ----------------------------------------------------------------------------- """ From 745e759b952a0e25b95302132ab78206dd144fdf Mon Sep 17 00:00:00 2001 From: Melek Derman <48313913+melekderman@users.noreply.github.com> Date: Sun, 15 Feb 2026 01:51:54 -0800 Subject: [PATCH 2/5] add client and update documentation --- .github/workflows/ci.yml | 56 +++- .github/workflows/docs.yml | 2 +- INSTALL.md | 129 ++++++++ README.md | 58 +++- docs/api.rst | 14 + docs/getting_started.rst | 50 ++- docs/index.rst | 1 + docs/requirements.txt | 8 +- docs/user_guide.rst | 442 ++++++++++++++++++++++++++ pyepics/__init__.py | 4 + pyepics/client.py | 617 +++++++++++++++++++++++++++++++++++++ pyepics/converters/hdf5.py | 4 +- pyepics/plotting.py | 308 ++++++++++++++++++ pyepics/readers/eadl.py | 5 +- pyepics/readers/eedl.py | 5 +- pyepics/readers/epdl.py | 5 +- pyepics/utils/constants.py | 7 +- pyepics/utils/parsing.py | 2 +- pyproject.toml | 83 +++++ tests/generate_report.py | 2 +- tests/test_client.py | 484 +++++++++++++++++++++++++++++ 21 files changed, 2255 insertions(+), 31 deletions(-) create mode 100644 INSTALL.md create mode 100644 docs/user_guide.rst create mode 100644 pyepics/client.py create mode 100644 pyepics/plotting.py create mode 100644 pyproject.toml create mode 100644 tests/test_client.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb19c86..35040bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install package with dev dependencies run: | python -m pip install --upgrade pip - pip install numpy h5py endf pytest + pip install -e ".[dev]" - name: Run tests run: | @@ -50,3 +50,55 @@ jobs: - name: Ruff check run: ruff check pyepics/ + + build: + name: Build distributions + runs-on: ubuntu-latest + needs: [test, lint] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install build tools + run: | + python -m pip install --upgrade pip + pip install build + + - name: Build sdist and wheel + run: python -m build + + - name: Verify distributions + run: | + pip install dist/*.whl + python -c "import pyepics; print(pyepics.__version__)" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + # Template for PyPI publishing — enable when ready. + # Uses trusted publishing (OIDC), no secrets required. + # See: https://docs.pypi.org/trusted-publishers/ + # + # publish: + # name: Publish to PyPI + # runs-on: ubuntu-latest + # needs: [build] + # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') + # permissions: + # id-token: write + # environment: + # name: pypi + # url: https://pypi.org/p/pyepics-data + # steps: + # - uses: actions/download-artifact@v4 + # with: + # name: dist + # path: dist/ + # - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e9fde53..322859e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install numpy h5py endf sphinx sphinx-rtd-theme myst-parser + pip install -e ".[dev]" - name: Build docs (strict — fail on warnings) run: | diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..73d756b --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,129 @@ +# Installing PyEPICS + +## Supported Python Versions + +PyEPICS requires **Python 3.11 or later** (3.11, 3.12, 3.13). + +## Quick Install from PyPI + +```bash +pip install pyepics-data +``` + +This installs the core package with the minimum required dependencies +(`numpy`, `h5py`, `endf`). + +## Optional Extras + +PyEPICS defines optional dependency groups you can install as needed: + +| Extra | What it adds | Install command | +|------------|-------------------------------------|------------------------------------------| +| `download` | `requests`, `beautifulsoup4` | `pip install "pyepics-data[download]"` | +| `pandas` | `pandas` | `pip install "pyepics-data[pandas]"` | +| `plot` | `matplotlib` | `pip install "pyepics-data[plot]"` | +| `all` | All optional dependencies | `pip install "pyepics-data[all]"` | +| `dev` | Testing + linting + docs tooling | `pip install "pyepics-data[dev]"` | + +### Examples + +```bash +# Core only (reading ENDF files and converting to HDF5) +pip install pyepics-data + +# With plotting and pandas for interactive exploration +pip install "pyepics-data[plot,pandas]" + +# Everything (including download support) +pip install "pyepics-data[all]" +``` + +## Developer Install + +Clone the repository and install in editable mode with development +dependencies: + +```bash +git clone https://github.com/melekderman/PyEPICS.git +cd PyEPICS + +# Create a virtual environment (recommended) +python -m venv .venv +source .venv/bin/activate # Linux/macOS +# .venv\Scripts\activate # Windows + +# Editable install with dev dependencies +pip install -e ".[dev]" +``` + +This gives you: + +- `pytest` for running tests +- `ruff` for linting +- `sphinx`, `sphinx-rtd-theme`, `myst-parser` for building docs +- All optional runtime dependencies (`pandas`, `matplotlib`, etc.) + +## Running Tests + +```bash +# Run all tests +python -m pytest tests/ -v + +# Run only the client API tests +python -m pytest tests/test_client.py -v + +# Run with coverage (if pytest-cov is installed) +python -m pytest tests/ --cov=pyepics --cov-report=term-missing +``` + +## Building Documentation + +```bash +cd docs +pip install -r requirements.txt # if not using [dev] extra +make html +``` + +The built HTML will be in `docs/_build/html/`. + +## Building Distributions + +To build source and wheel distributions for publishing: + +```bash +pip install build +python -m build +``` + +This produces: + +``` +dist/ +├── pyepics_data-0.1.0.tar.gz # sdist +└── pyepics_data-0.1.0-py3-none-any.whl # wheel +``` + +## Verifying the Install + +After installation, verify everything works: + +```python +import pyepics +print(pyepics.__version__) # "0.1.0" + +# Check that the client API is available +from pyepics import EPICSClient +print("EPICSClient loaded successfully") +``` + +## Platform Notes + +- **macOS / Linux / Windows**: All supported via pure-Python code. + Binary dependencies (`numpy`, `h5py`) provide pre-built wheels for + all major platforms. +- **Conda**: You can also install dependencies via conda-forge: + + ```bash + conda install numpy h5py + pip install pyepics-data + ``` diff --git a/README.md b/README.md index 3061e23..1a7c123 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ PyEPICS parses EEDL, EPDL, and EADL files from the [LLNL EPICS 2025](https://nuc PyEPICS/ ├── pyepics/ # Source code │ ├── __init__.py # Public API +│ ├── client.py # High-level element query API (EPICSClient) +│ ├── plotting.py # Optional plotting helpers (matplotlib) │ ├── cli.py # Batch processing CLI │ ├── exceptions.py # Custom exception hierarchy │ ├── pyeedl_compat.py # Backward-compatibility shim for legacy PyEEDL code @@ -76,17 +78,29 @@ utils ← models ← readers ← converters (raw_hdf5 / mcdc_hdf5) | **models** | Typed `dataclass` records (`EEDLDataset`, `EPDLDataset`, `EADLDataset`) — the sole output of readers and sole input to converters | | **readers** | `EEDLReader`, `EPDLReader`, `EADLReader` — parse ENDF files via the `endf` library and return model instances | | **converters** | Two-step conversion: `raw_hdf5` (full-fidelity) and `mcdc_hdf5` (transport-optimised) | +| **client** | High-level `EPICSClient` for querying/comparing element properties | +| **plotting** | Optional visualisation helpers (requires `matplotlib`) | | **io** | Dataset download from LLNL | | **cli** | Batch processing for the full pipeline | ## Installation ```bash -pip install numpy h5py endf -# For downloading data from LLNL: -pip install requests beautifulsoup4 +# From PyPI (when published) +pip install pyepics-data + +# With all optional dependencies +pip install "pyepics-data[all]" + +# From source (editable, for development) +git clone https://github.com/melekderman/PyEPICS.git +cd PyEPICS +pip install -e ".[dev]" ``` +See [INSTALL.md](INSTALL.md) for full details on optional extras, developer +setup, and platform notes. + --- ## Data Pipeline @@ -216,10 +230,44 @@ download_all() # downloads all three ## Quick Start +### High-Level Client API + +```python +from pyepics import EPICSClient + +client = EPICSClient("data/endf") + +# Query a single element (by symbol, name, or Z) +fe = client.get_element("Fe") +print(fe.Z, fe.symbol) # 26, "Fe" +print(fe.binding_energies) # {'K': 7112.0, 'L1': 844.6, ...} +print(fe.electron_cross_section_labels) # ['xs_tot', 'xs_el', ...] + +# Compare multiple elements +rows = client.compare(["Fe", "Cu", "Au"]) + +# DataFrame output (requires pandas) +df = client.compare_df(["Fe", "Cu", "Au"]) + +# Get raw cross-section arrays +energy, xs = client.get_cross_section("Fe", "xs_tot") +``` + +### Plotting (requires matplotlib) + +```python +from pyepics.plotting import plot_cross_sections, compare_cross_sections + +plot_cross_sections(client, "Fe") +compare_cross_sections(client, ["C", "Fe", "Au"], "xs_tot") +``` + +### Low-Level Reader API + ```python from pyepics import EEDLReader -# Parse an EEDL file +# Parse an EEDL file directly reader = EEDLReader() dataset = reader.read("data/endf/eedl/EEDL.ZA026000.endf") print(dataset.Z, dataset.symbol) # 26, "Fe" @@ -242,7 +290,7 @@ PyEPICSError ## Running Tests ```bash -pip install pytest +pip install -e ".[dev]" python -m pytest tests/ -v ``` diff --git a/docs/api.rst b/docs/api.rst index f1f8433..e51d8e7 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,6 +1,20 @@ API Reference ============= +Client API +---------- + +.. automodule:: pyepics.client + :members: + :undoc-members: + +Plotting +-------- + +.. automodule:: pyepics.plotting + :members: + :undoc-members: + Readers ------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 66a80b0..efa9b2e 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -4,20 +4,62 @@ Getting Started Installation ------------ +From PyPI (when published): + +.. code-block:: bash + + pip install pyepics-data + +With optional extras: + +.. code-block:: bash + + # Plotting and pandas support + pip install "pyepics-data[plot,pandas]" + + # All optional dependencies + pip install "pyepics-data[all]" + +From source (development): + .. code-block:: bash - pip install numpy h5py endf + git clone https://github.com/melekderman/PyEPICS.git + cd PyEPICS + pip install -e ".[dev]" - # For downloading data from LLNL: - pip install requests beautifulsoup4 +See `INSTALL.md `_ +for full details. Quick Start ----------- +High-level client API: + +.. code-block:: python + + from pyepics import EPICSClient + + client = EPICSClient("data/endf") + + # Query an element by symbol, name, or atomic number + fe = client.get_element("Fe") + print(fe.Z, fe.symbol) # 26, "Fe" + print(fe.binding_energies) # {'K': 7112.0, 'L1': 844.6, ...} + print(fe.electron_cross_section_labels) + + # Compare multiple elements + df = client.compare_df(["Fe", "Cu", "Au"]) + +Low-level reader API: + .. code-block:: python from pyepics import EEDLReader reader = EEDLReader() - dataset = reader.read("eedl/EEDL.ZA026000.endf") + dataset = reader.read("data/endf/eedl/EEDL.ZA026000.endf") print(dataset.Z, dataset.symbol) # 26, "Fe" + +See :doc:`user_guide` for more examples including plotting and reading +HDF5 output files. diff --git a/docs/index.rst b/docs/index.rst index e440154..0800ace 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,6 +6,7 @@ PyEPICS Documentation :caption: Contents getting_started + user_guide pipeline data_sources api diff --git a/docs/requirements.txt b/docs/requirements.txt index 4b48293..d30c65b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,6 +1,8 @@ numpy h5py endf -sphinx -sphinx-rtd-theme -myst-parser +sphinx>=7.0 +sphinx-rtd-theme>=2.0 +myst-parser>=3.0 +matplotlib +pandas diff --git a/docs/user_guide.rst b/docs/user_guide.rst new file mode 100644 index 0000000..2857cc9 --- /dev/null +++ b/docs/user_guide.rst @@ -0,0 +1,442 @@ +User Guide +========== + +This guide covers practical examples for querying element data, comparing +elements, plotting, and working with generated HDF5 files. + +.. contents:: Table of Contents + :local: + :depth: 2 + + +Querying a Single Element +-------------------------- + +The :class:`~pyepics.client.EPICSClient` is the main entry point for +interactive exploration. Point it at the directory containing your ENDF +files: + +.. code-block:: python + + from pyepics import EPICSClient + + client = EPICSClient("data/endf") + + # Look up iron by symbol, name, or atomic number — all equivalent: + fe = client.get_element("Fe") + fe = client.get_element("Iron") + fe = client.get_element(26) + + # Inspect scalar metadata + print(fe.Z) # 26 + print(fe.symbol) # "Fe" + print(fe.name) # "Iron" + + # List available electron (EEDL) cross-section keys + print(fe.electron_cross_section_labels) + # ['xs_tot', 'xs_el', 'xs_lge', 'xs_brem', 'xs_exc', 'xs_ion', 'xs_K', ...] + + # List available photon (EPDL) cross-section keys + print(fe.photon_cross_section_labels) + # ['xs_tot', 'xs_coherent', 'xs_incoherent', 'xs_photoelectric', ...] + + # Subshell binding energies from EADL + print(fe.binding_energies) + # {'K': 7112.0, 'L1': 844.6, 'L2': 719.9, 'L3': 706.8, ...} + + # Number of subshells and their labels + print(fe.n_subshells, fe.subshells) + + # Get everything as a flat dictionary + summary = fe.to_dict() + print(summary.keys()) + + +Retrieving Cross-Section Arrays +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To get the raw energy/cross-section NumPy arrays: + +.. code-block:: python + + energy, xs = client.get_cross_section("Fe", "xs_tot", library="EEDL") + print(energy.shape, xs.shape) # e.g. (92,) (92,) + + # Same for photon cross sections + energy, xs = client.get_cross_section("Fe", "xs_tot", library="EPDL") + + +Comparing Multiple Elements +---------------------------- + +Compare scalar properties across elements: + +.. code-block:: python + + # Returns a list of dicts + rows = client.compare(["Fe", "Cu", "Au"]) + for r in rows: + print(f"{r['symbol']:>3s} Z={r['Z']:>3d} subshells={r['n_subshells']}") + + # Filter to specific properties + rows = client.compare( + ["H", "He", "Li", "Be", "B", "C"], + properties=["Z", "symbol", "n_subshells"], + ) + +If ``pandas`` is installed, get a DataFrame directly: + +.. code-block:: python + + df = client.compare_df(["Fe", "Cu", "Au"]) + print(df[["symbol", "Z", "n_subshells"]]) + # symbol Z n_subshells + # 0 Fe 26 11 + # 1 Cu 29 12 + # 2 Au 79 25 + + +Binding Energy Table +~~~~~~~~~~~~~~~~~~~~~ + +Build a binding-energy table across elements (requires ``pandas``): + +.. code-block:: python + + df = client.binding_energy_table(range(26, 31)) + print(df[["Z", "K", "L1", "L2", "L3"]]) + + +Plotting +-------- + +The :mod:`pyepics.plotting` module provides convenience wrappers for +common plots. Requires ``matplotlib``: + +.. code-block:: bash + + pip install matplotlib + +Cross Sections for One Element +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from pyepics.plotting import plot_cross_sections + + # All electron cross sections for iron + plot_cross_sections(client, "Fe") + + # Specific labels only + plot_cross_sections(client, "Fe", labels=["xs_tot", "xs_el", "xs_brem"]) + + # Photon cross sections + plot_cross_sections(client, "Fe", library="EPDL") + + +Compare a Cross Section Across Elements +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from pyepics.plotting import compare_cross_sections + + compare_cross_sections(client, ["C", "Al", "Fe", "Cu", "Au"], "xs_tot") + + +Binding Energies vs. Atomic Number +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from pyepics.plotting import plot_binding_energies + + # All subshells + plot_binding_energies(client, range(1, 100)) + + # Only K shell + plot_binding_energies(client, range(1, 100), subshell="K") + + +Shell-by-Shell Bar Chart +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from pyepics.plotting import plot_shell_binding_energies + + plot_shell_binding_energies(client, "Au") + + +Using Custom Axes +~~~~~~~~~~~~~~~~~~~ + +All plotting functions accept an ``ax`` parameter so you can compose +multi-panel figures: + +.. code-block:: python + + import matplotlib.pyplot as plt + from pyepics.plotting import plot_cross_sections + + fig, axes = plt.subplots(1, 2, figsize=(14, 5)) + plot_cross_sections(client, "Fe", ax=axes[0], show=False) + plot_cross_sections(client, "Au", ax=axes[1], show=False) + plt.tight_layout() + plt.show() + + +.. _user_guide_hdf5: + +Reading Generated HDF5 Files +----------------------------- + +After running the pipeline (see :doc:`pipeline`), PyEPICS produces HDF5 +files in two output directories: + +.. code-block:: text + + data/ + ├── raw/ + │ ├── electron/ ← H.h5, He.h5, … (EEDL) + │ ├── photon/ ← H.h5, He.h5, … (EPDL) + │ └── atomic/ ← H.h5, He.h5, … (EADL) + └── mcdc/ + ├── electron/ ← H.h5, He.h5, … (EEDL) + ├── photon/ ← H.h5, He.h5, … (EPDL) + └── atomic/ ← H.h5, He.h5, … (EADL) + +Each file is named ``.h5`` (e.g. ``Fe.h5`` for iron, ``Au.h5`` for +gold). + + +Locating Files +~~~~~~~~~~~~~~~ + +.. code-block:: python + + from pathlib import Path + + raw_electron_dir = Path("data/raw/electron") + files = sorted(raw_electron_dir.glob("*.h5")) + print(files) # [PosixPath('data/raw/electron/Ac.h5'), ...] + + +Inspecting HDF5 Structure +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Use ``h5py`` to inspect the internal structure: + +.. code-block:: python + + import h5py + + with h5py.File("data/raw/electron/Fe.h5", "r") as f: + # Print all top-level groups + print(list(f.keys())) + # e.g. ['metadata', 'total_cross_section', 'elastic_scatter', ...] + + # Walk every group and dataset + def print_tree(name, obj): + indent = " " * name.count("/") + if isinstance(obj, h5py.Dataset): + print(f"{indent}{name} shape={obj.shape} dtype={obj.dtype}") + else: + print(f"{indent}{name}/") + f.visititems(print_tree) + + +Raw Electron (EEDL) HDF5 Schema +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + /metadata + Z (int), symbol (str), library (str="EEDL"), format (str="raw") + + /total_cross_section + energy (N,) float64 [eV] + xs (N,) float64 [barns] + + /elastic_scatter/total + energy, xs + + /elastic_scatter/large_angle + energy, xs + + /elastic_scatter/large_angle_angular_distribution + inc_energy, cosine, probability + + /bremsstrahlung/cross_section + energy, xs + + /bremsstrahlung/spectra + inc_energy, photon_energy, probability + + /bremsstrahlung/average_energy_loss + energy, avg_loss + + /excitation/cross_section + energy, xs + + /excitation/average_energy_loss + energy, avg_loss + + /ionization/total + energy, xs + + /ionization//cross_section (e.g. /ionization/K/cross_section) + energy, xs + + /ionization//energy_spectrum + inc_energy, secondary_energy, probability + + +MCDC Electron (EEDL) HDF5 Schema +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: text + + /metadata + Z, symbol, library="EEDL", format="mcdc" + + /electron_reactions/energy_grid (M,) float64 [eV] + + /electron_reactions/total_cross_section (M,) + /electron_reactions/elastic_scatter (M,) + /electron_reactions/large_angle_elastic (M,) + /electron_reactions/bremsstrahlung (M,) + /electron_reactions/excitation (M,) + /electron_reactions/ionization (M,) + + /electron_reactions/large_angle_scattering_cosine/ + grid, offset, cosine, pdf + + /electron_reactions/small_angle_scattering_cosine/ + grid, offset, cosine, pdf + + /electron_reactions/bremsstrahlung_spectra/ + grid, offset, photon_energy, pdf + + /electron_reactions/excitation_average_energy_loss (M,) + /electron_reactions/bremsstrahlung_average_energy_loss (M,) + + /electron_reactions//cross_section (M,) + /electron_reactions//binding_energy scalar + /electron_reactions//energy_spectrum/ + grid, offset, secondary_energy, pdf + + +Reading Cross Sections into a DataFrame +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import h5py + import numpy as np + + with h5py.File("data/raw/electron/Fe.h5", "r") as f: + energy = f["total_cross_section/energy"][:] + xs = f["total_cross_section/xs"][:] + + # If pandas is available: + import pandas as pd + + df = pd.DataFrame({"energy_eV": energy, "xs_barns": xs}) + print(df.head()) + + # Plot directly + df.plot(x="energy_eV", y="xs_barns", logx=True, logy=True, + title="Fe Total Electron Cross Section") + + +Reading MCDC Data +~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import h5py + import numpy as np + + with h5py.File("data/mcdc/electron/Fe.h5", "r") as f: + energy = f["electron_reactions/energy_grid"][:] + xs_tot = f["electron_reactions/total_cross_section"][:] + xs_el = f["electron_reactions/elastic_scatter"][:] + + # All cross sections are on the same energy grid + assert energy.shape == xs_tot.shape == xs_el.shape + + +Reading Atomic Relaxation Data +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import h5py + + with h5py.File("data/raw/atomic/Fe.h5", "r") as f: + for subshell in f.keys(): + if subshell == "metadata": + continue + grp = f[subshell] + be = grp.attrs.get("binding_energy_eV", "N/A") + n = grp.attrs.get("n_electrons", "N/A") + nt = grp["transition_energy"].shape[0] if "transition_energy" in grp else 0 + print(f"{subshell}: BE={be} eV, electrons={n}, transitions={nt}") + + +Reading Photon Data +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import h5py + + with h5py.File("data/raw/photon/Fe.h5", "r") as f: + # Cross sections + energy = f["total_cross_section/energy"][:] + xs = f["total_cross_section/xs"][:] + + # Form factors + x = f["form_factors/coherent/x"][:] + y = f["form_factors/coherent/y"][:] + + # MCDC format + with h5py.File("data/mcdc/photon/Fe.h5", "r") as f: + energy = f["photon_reactions/energy_grid"][:] + xs_tot = f["photon_reactions/total_cross_section"][:] + + +Converting to pandas and Plotting +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + import h5py + import pandas as pd + import matplotlib.pyplot as plt + + with h5py.File("data/raw/electron/Fe.h5", "r") as f: + df = pd.DataFrame({ + "energy": f["total_cross_section/energy"][:], + "total": f["total_cross_section/xs"][:], + }) + + # Add more cross sections if available + if "elastic_scatter/total" in f: + e = f["elastic_scatter/total/energy"][:] + xs = f["elastic_scatter/total/xs"][:] + df_el = pd.DataFrame({"energy": e, "elastic": xs}) + df = df.merge(df_el, on="energy", how="outer").sort_values("energy") + + fig, ax = plt.subplots(figsize=(8, 5)) + ax.loglog(df["energy"], df["total"], label="Total") + if "elastic" in df.columns: + ax.loglog(df["energy"], df["elastic"], label="Elastic") + ax.set_xlabel("Energy (eV)") + ax.set_ylabel("Cross Section (barns)") + ax.set_title("Fe — Electron Cross Sections (raw)") + ax.legend() + ax.grid(True, alpha=0.3) + plt.tight_layout() + plt.show() diff --git a/pyepics/__init__.py b/pyepics/__init__.py index f9d6dd6..1604f24 100644 --- a/pyepics/__init__.py +++ b/pyepics/__init__.py @@ -59,6 +59,7 @@ create_raw_hdf5, create_mcdc_hdf5, ) +from pyepics.client import EPICSClient, ElementProperties from pyepics.exceptions import ( PyEPICSError, ParseError, @@ -71,6 +72,9 @@ __all__ = [ # Version "__version__", + # High-level client API + "EPICSClient", + "ElementProperties", # Readers "EEDLReader", "EADLReader", diff --git a/pyepics/client.py b/pyepics/client.py new file mode 100644 index 0000000..efc5ce8 --- /dev/null +++ b/pyepics/client.py @@ -0,0 +1,617 @@ +#!/usr/bin/env python3 +# ----------------------------------------------------------------------------- +# Copyright (c) 2026 Melek Derman +# +# SPDX-License-Identifier: BSD-3-Clause +# ----------------------------------------------------------------------------- + +""" +High-level user API for querying element properties + +This module provides a convenient, user-friendly interface to query, +compare, and visualise atomic / electron / photon properties from the +EPICS (EEDL / EPDL / EADL) datasets. + +Usage +----- +>>> from pyepics.client import EPICSClient +>>> client = EPICSClient("data/endf") +>>> props = client.get_properties("Fe") +>>> props["Z"] +26 +>>> df = client.compare(["Fe", "Cu", "Au"], properties=["Z", "binding_energies"]) +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Any, Sequence, Union + +import numpy as np + +from pyepics.exceptions import PyEPICSError, ValidationError +from pyepics.models.records import ( + EADLDataset, + EEDLDataset, + EPDLDataset, +) +from pyepics.readers.eadl import EADLReader +from pyepics.readers.eedl import EEDLReader +from pyepics.readers.epdl import EPDLReader +from pyepics.utils.constants import PERIODIC_TABLE + +logger = logging.getLogger(__name__) + +# Type alias for element identifiers +ElementID = Union[int, str] + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +_SYMBOL_TO_Z: dict[str, int] = { + v["symbol"].lower(): k for k, v in PERIODIC_TABLE.items() +} +_NAME_TO_Z: dict[str, int] = { + v["name"].lower(): k for k, v in PERIODIC_TABLE.items() +} + + +def _resolve_element(element: ElementID) -> tuple[int, str]: + """Resolve an element identifier to (Z, symbol). + + Parameters + ---------- + element : int or str + Atomic number, element symbol (case-insensitive), or element name. + + Returns + ------- + tuple[int, str] + ``(Z, symbol)`` + + Raises + ------ + ValidationError + If the element cannot be resolved. + """ + if isinstance(element, (int, np.integer)): + z = int(element) + if z not in PERIODIC_TABLE: + raise ValidationError( + f"Atomic number Z={z} is outside the valid range [1, 118]." + ) + return z, PERIODIC_TABLE[z]["symbol"] + + if isinstance(element, str): + key = element.strip().lower() + # Try symbol first + if key in _SYMBOL_TO_Z: + z = _SYMBOL_TO_Z[key] + return z, PERIODIC_TABLE[z]["symbol"] + # Try full name + if key in _NAME_TO_Z: + z = _NAME_TO_Z[key] + return z, PERIODIC_TABLE[z]["symbol"] + raise ValidationError( + f"Unknown element identifier: {element!r}. " + f"Provide an atomic number (1–118), symbol (e.g. 'Fe'), " + f"or element name (e.g. 'Iron')." + ) + + raise ValidationError( + f"Element must be an int or str, got {type(element).__name__}." + ) + + +def _find_endf_file( + data_dir: Path, library: str, z: int +) -> Path | None: + """Locate an ENDF file for the given library and atomic number.""" + lib_upper = library.upper() + subdir_map = {"EEDL": "eedl", "EPDL": "epdl", "EADL": "eadl"} + subdir = subdir_map.get(lib_upper) + if subdir is None: + return None + folder = data_dir / subdir + if not folder.is_dir(): + return None + # Pattern: EEDL.ZA026000.endf + za_str = f"{z:03d}000" + fname = f"{lib_upper}.ZA{za_str}.endf" + path = folder / fname + return path if path.is_file() else None + + +# --------------------------------------------------------------------------- +# Element result container +# --------------------------------------------------------------------------- + +class ElementProperties: + """Container for all properties of a single element. + + This object behaves like a dictionary but also provides attribute + access for convenience. + + Attributes + ---------- + Z : int + Atomic number. + symbol : str + Element symbol. + name : str + Element name. + electron : EEDLDataset or None + Parsed EEDL data (if available). + photon : EPDLDataset or None + Parsed EPDL data (if available). + atomic : EADLDataset or None + Parsed EADL data (if available). + """ + + def __init__( + self, + z: int, + symbol: str, + name: str, + *, + electron: EEDLDataset | None = None, + photon: EPDLDataset | None = None, + atomic: EADLDataset | None = None, + ) -> None: + self.Z = z + self.symbol = symbol + self.name = name + self.electron = electron + self.photon = photon + self.atomic = atomic + + # -- Derived scalar helpers -- + + @property + def binding_energies(self) -> dict[str, float]: + """Subshell binding energies (eV) from EADL data. + + Returns + ------- + dict[str, float] + Mapping of subshell label → binding energy. Empty if no + EADL data is loaded. + """ + if self.atomic is None: + return {} + return { + name: sub.binding_energy_eV + for name, sub in self.atomic.subshells.items() + } + + @property + def electron_cross_section_labels(self) -> list[str]: + """List of available electron cross-section keys.""" + if self.electron is None: + return [] + return list(self.electron.cross_sections.keys()) + + @property + def photon_cross_section_labels(self) -> list[str]: + """List of available photon cross-section keys.""" + if self.photon is None: + return [] + return list(self.photon.cross_sections.keys()) + + @property + def subshells(self) -> list[str]: + """List of subshell labels from EADL data.""" + if self.atomic is None: + return [] + return list(self.atomic.subshells.keys()) + + @property + def n_subshells(self) -> int: + """Number of subshells from EADL data.""" + if self.atomic is None: + return 0 + return self.atomic.n_subshells + + def to_dict(self) -> dict[str, Any]: + """Export a flat summary dictionary. + + Returns + ------- + dict[str, Any] + Includes scalar metadata, binding energies, and lists of + available cross-section keys. Array data is *not* included + to keep the output concise. + """ + d: dict[str, Any] = { + "Z": self.Z, + "symbol": self.symbol, + "name": self.name, + "n_subshells": self.n_subshells, + "binding_energies": self.binding_energies, + "electron_cross_sections": self.electron_cross_section_labels, + "photon_cross_sections": self.photon_cross_section_labels, + "subshells": self.subshells, + } + if self.electron is not None: + d["atomic_weight_ratio"] = self.electron.atomic_weight_ratio + elif self.photon is not None: + d["atomic_weight_ratio"] = self.photon.atomic_weight_ratio + elif self.atomic is not None: + d["atomic_weight_ratio"] = self.atomic.atomic_weight_ratio + return d + + def __repr__(self) -> str: + libs = [] + if self.electron: + libs.append("EEDL") + if self.photon: + libs.append("EPDL") + if self.atomic: + libs.append("EADL") + return ( + f"ElementProperties(Z={self.Z}, symbol={self.symbol!r}, " + f"name={self.name!r}, libraries=[{', '.join(libs)}])" + ) + + def __getitem__(self, key: str) -> Any: + return self.to_dict()[key] + + def __contains__(self, key: str) -> bool: + return key in self.to_dict() + + +# --------------------------------------------------------------------------- +# Main client +# --------------------------------------------------------------------------- + +class EPICSClient: + """High-level interface for querying EPICS element data. + + Parameters + ---------- + data_dir : str or Path + Root directory containing ``eedl/``, ``epdl/``, ``eadl/`` + sub-folders with ENDF files. Defaults to ``"data/endf"`` + relative to the current working directory. + + Examples + -------- + >>> client = EPICSClient("data/endf") + >>> fe = client.get_element("Fe") + >>> fe.Z + 26 + >>> fe.binding_energies # doctest: +SKIP + {'K': 7112.0, 'L1': 844.6, ...} + """ + + def __init__(self, data_dir: str | Path = "data/endf") -> None: + self._data_dir = Path(data_dir) + self._eedl_reader = EEDLReader() + self._epdl_reader = EPDLReader() + self._eadl_reader = EADLReader() + # Cache: Z -> (eedl, epdl, eadl) + self._cache: dict[ + int, tuple[EEDLDataset | None, EPDLDataset | None, EADLDataset | None] + ] = {} + + # -- Internal loading -- + + def _load( + self, z: int, *, libraries: Sequence[str] = ("EEDL", "EPDL", "EADL") + ) -> tuple[EEDLDataset | None, EPDLDataset | None, EADLDataset | None]: + """Load and cache datasets for element *z*.""" + if z in self._cache: + cached = self._cache[z] + # If all requested libraries are already cached, return + eedl, epdl, eadl = cached + need_eedl = "EEDL" in libraries and eedl is None + need_epdl = "EPDL" in libraries and epdl is None + need_eadl = "EADL" in libraries and eadl is None + if not (need_eedl or need_epdl or need_eadl): + return cached + # Otherwise, load missing ones + else: + eedl, epdl, eadl = None, None, None + + lib_upper = {lib.upper() for lib in libraries} + + if "EEDL" in lib_upper and eedl is None: + path = _find_endf_file(self._data_dir, "EEDL", z) + if path is not None: + try: + eedl = self._eedl_reader.read(path) + except Exception as exc: + logger.warning("Failed to read EEDL for Z=%d: %s", z, exc) + + if "EPDL" in lib_upper and epdl is None: + path = _find_endf_file(self._data_dir, "EPDL", z) + if path is not None: + try: + epdl = self._epdl_reader.read(path) + except Exception as exc: + logger.warning("Failed to read EPDL for Z=%d: %s", z, exc) + + if "EADL" in lib_upper and eadl is None: + path = _find_endf_file(self._data_dir, "EADL", z) + if path is not None: + try: + eadl = self._eadl_reader.read(path) + except Exception as exc: + logger.warning("Failed to read EADL for Z=%d: %s", z, exc) + + result = (eedl, epdl, eadl) + self._cache[z] = result + return result + + # -- Public API -- + + def get_element( + self, + element: ElementID, + *, + libraries: Sequence[str] = ("EEDL", "EPDL", "EADL"), + ) -> ElementProperties: + """Retrieve all available data for an element. + + Parameters + ---------- + element : int or str + Atomic number, symbol (e.g. ``"Fe"``), or full name + (e.g. ``"Iron"``). + libraries : sequence of str, optional + Which EPICS libraries to load. Defaults to all three. + + Returns + ------- + ElementProperties + Container with parsed datasets and derived properties. + + Raises + ------ + ValidationError + If *element* cannot be resolved. + + Examples + -------- + >>> client = EPICSClient("data/endf") + >>> fe = client.get_element("Fe") + >>> fe.symbol + 'Fe' + """ + z, symbol = _resolve_element(element) + name = PERIODIC_TABLE[z]["name"] + eedl, epdl, eadl = self._load(z, libraries=libraries) + return ElementProperties( + z, symbol, name, electron=eedl, photon=epdl, atomic=eadl + ) + + def get_properties( + self, + element: ElementID, + *, + libraries: Sequence[str] = ("EEDL", "EPDL", "EADL"), + ) -> dict[str, Any]: + """Return a flat summary dictionary for an element. + + This is a convenience wrapper around :meth:`get_element` that + returns a plain ``dict`` rather than an :class:`ElementProperties` + object. + + Parameters + ---------- + element : int or str + Element identifier. + libraries : sequence of str, optional + Libraries to load. + + Returns + ------- + dict[str, Any] + See :meth:`ElementProperties.to_dict`. + """ + return self.get_element(element, libraries=libraries).to_dict() + + def compare( + self, + elements: Sequence[ElementID], + *, + properties: Sequence[str] | None = None, + libraries: Sequence[str] = ("EEDL", "EPDL", "EADL"), + ) -> list[dict[str, Any]]: + """Compare properties across multiple elements. + + Parameters + ---------- + elements : sequence of int or str + Elements to compare. + properties : sequence of str or None, optional + If given, only include these keys in each row. + Defaults to all scalar properties. + libraries : sequence of str, optional + Libraries to load. + + Returns + ------- + list[dict[str, Any]] + One dict per element. If ``pandas`` is available, call + :meth:`compare_df` instead for a DataFrame. + + Examples + -------- + >>> client = EPICSClient("data/endf") + >>> rows = client.compare(["H", "He", "Li"]) + >>> [r["symbol"] for r in rows] + ['H', 'He', 'Li'] + """ + rows: list[dict[str, Any]] = [] + for elem in elements: + d = self.get_properties(elem, libraries=libraries) + if properties is not None: + d = {k: d[k] for k in properties if k in d} + rows.append(d) + return rows + + def compare_df( + self, + elements: Sequence[ElementID], + *, + properties: Sequence[str] | None = None, + libraries: Sequence[str] = ("EEDL", "EPDL", "EADL"), + ): + """Compare elements and return a pandas DataFrame. + + Requires ``pandas`` to be installed. + + Parameters + ---------- + elements : sequence of int or str + Elements to compare. + properties : sequence of str or None, optional + Subset of properties to include. + libraries : sequence of str, optional + Libraries to load. + + Returns + ------- + pandas.DataFrame + One row per element, columns are property names. + + Raises + ------ + ImportError + If ``pandas`` is not installed. + """ + try: + import pandas as pd + except ImportError: + raise ImportError( + "pandas is required for compare_df(). " + "Install it with: pip install pandas" + ) from None + + rows = self.compare( + elements, properties=properties, libraries=libraries + ) + return pd.DataFrame(rows) + + def binding_energy_table( + self, + elements: Sequence[ElementID], + ): + """Build a binding-energy table (requires pandas). + + Parameters + ---------- + elements : sequence of int or str + Elements to include. + + Returns + ------- + pandas.DataFrame + Rows = elements, columns = subshell labels. + Missing subshells are ``NaN``. + + Raises + ------ + ImportError + If ``pandas`` is not installed. + """ + try: + import pandas as pd + except ImportError: + raise ImportError( + "pandas is required for binding_energy_table(). " + "Install it with: pip install pandas" + ) from None + + records = [] + for elem in elements: + ep = self.get_element(elem, libraries=["EADL"]) + row: dict[str, Any] = {"Z": ep.Z, "symbol": ep.symbol, "name": ep.name} + row.update(ep.binding_energies) + records.append(row) + return pd.DataFrame(records).set_index("symbol") + + def get_cross_section( + self, + element: ElementID, + label: str, + *, + library: str = "EEDL", + ) -> tuple[np.ndarray, np.ndarray]: + """Retrieve a specific cross-section array. + + Parameters + ---------- + element : int or str + Element identifier. + label : str + Cross-section label (e.g. ``"xs_tot"``). + library : str + ``"EEDL"`` or ``"EPDL"``. + + Returns + ------- + tuple[numpy.ndarray, numpy.ndarray] + ``(energy_eV, cross_section_barns)`` + + Raises + ------ + KeyError + If the label does not exist for this element. + """ + ep = self.get_element(element, libraries=[library]) + lib_upper = library.upper() + if lib_upper == "EEDL": + ds = ep.electron + elif lib_upper == "EPDL": + ds = ep.photon + else: + raise ValidationError( + f"Cross sections are only in EEDL/EPDL, not {library!r}." + ) + + if ds is None: + raise PyEPICSError( + f"No {lib_upper} data found for {ep.symbol} (Z={ep.Z}). " + f"Check that the ENDF files exist in {self._data_dir}." + ) + + if label not in ds.cross_sections: + available = list(ds.cross_sections.keys()) + raise KeyError( + f"Cross-section label {label!r} not found for " + f"{ep.symbol}. Available: {available}" + ) + + rec = ds.cross_sections[label] + return rec.energy.copy(), rec.cross_section.copy() + + # -- Cache management -- + + def clear_cache(self) -> None: + """Clear all cached datasets.""" + self._cache.clear() + + @property + def available_elements(self) -> list[int]: + """Return sorted list of atomic numbers with ENDF data on disk. + + Scans the data directory for EEDL files (as the primary indicator). + """ + eedl_dir = self._data_dir / "eedl" + if not eedl_dir.is_dir(): + return [] + zs = [] + for f in sorted(eedl_dir.iterdir()): + if f.suffix == ".endf" and f.stem.startswith("EEDL.ZA"): + try: + za_part = f.stem.split("ZA")[1] + z = int(za_part[:3]) + zs.append(z) + except (IndexError, ValueError): + pass + return sorted(zs) diff --git a/pyepics/converters/hdf5.py b/pyepics/converters/hdf5.py index e624e6b..63cde81 100644 --- a/pyepics/converters/hdf5.py +++ b/pyepics/converters/hdf5.py @@ -61,8 +61,8 @@ References ---------- -.. [1] HDF5 best practices, The HDF Group. -.. [2] ENDF-6 Formats Manual (ENDF-102). +- HDF5 best practices, The HDF Group. +- ENDF-6 Formats Manual (ENDF-102). """ from __future__ import annotations diff --git a/pyepics/plotting.py b/pyepics/plotting.py new file mode 100644 index 0000000..7e69b3b --- /dev/null +++ b/pyepics/plotting.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# ----------------------------------------------------------------------------- +# Copyright (c) 2026 Melek Derman +# +# SPDX-License-Identifier: BSD-3-Clause +# ----------------------------------------------------------------------------- + +""" +Optional plotting helpers for PyEPICS + +All functions in this module require ``matplotlib``. The core library +works without it — these are convenience wrappers for quick +visualisation. + +Usage +----- +>>> from pyepics.plotting import plot_cross_sections, plot_binding_energies +>>> plot_cross_sections(client, "Fe", labels=["xs_tot", "xs_el"]) # doctest: +SKIP +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Sequence + +import numpy as np + +if TYPE_CHECKING: + from pyepics.client import EPICSClient, ElementID + + +def _import_matplotlib(): + """Import matplotlib or raise a helpful error.""" + try: + import matplotlib.pyplot as plt + return plt + except ImportError: + raise ImportError( + "matplotlib is required for plotting. " + "Install it with: pip install matplotlib" + ) from None + + +def plot_cross_sections( + client: "EPICSClient", + element: "ElementID", + *, + labels: Sequence[str] | None = None, + library: str = "EEDL", + logx: bool = True, + logy: bool = True, + title: str | None = None, + ax: Any = None, + show: bool = True, +) -> Any: + """Plot cross sections for a single element. + + Parameters + ---------- + client : EPICSClient + Initialised client instance. + element : int or str + Element identifier. + labels : sequence of str or None, optional + Cross-section labels to plot. Defaults to all available. + library : str + ``"EEDL"`` or ``"EPDL"``. + logx, logy : bool + Use logarithmic axes. + title : str or None + Plot title. Auto-generated if ``None``. + ax : matplotlib.axes.Axes or None + Existing axes to draw on. Creates a new figure if ``None``. + show : bool + Call ``plt.show()`` at the end. + + Returns + ------- + matplotlib.axes.Axes + The axes object. + """ + plt = _import_matplotlib() + + ep = client.get_element(element, libraries=[library]) + lib_upper = library.upper() + ds = ep.electron if lib_upper == "EEDL" else ep.photon + if ds is None: + raise ValueError(f"No {lib_upper} data for {ep.symbol} (Z={ep.Z}).") + + xs_dict = ds.cross_sections + if labels is None: + labels = list(xs_dict.keys()) + + if ax is None: + fig, ax = plt.subplots(figsize=(8, 5)) + + for label in labels: + if label not in xs_dict: + continue + rec = xs_dict[label] + ax.plot(rec.energy, rec.cross_section, label=label) + + if logx: + ax.set_xscale("log") + if logy: + ax.set_yscale("log") + + ax.set_xlabel("Energy (eV)") + ax.set_ylabel("Cross Section (barns)") + ax.set_title( + title or f"{ep.symbol} (Z={ep.Z}) — {lib_upper} Cross Sections" + ) + ax.legend(fontsize="small", ncol=2) + ax.grid(True, which="both", alpha=0.3) + + if show: + plt.tight_layout() + plt.show() + return ax + + +def compare_cross_sections( + client: "EPICSClient", + elements: Sequence["ElementID"], + label: str, + *, + library: str = "EEDL", + logx: bool = True, + logy: bool = True, + title: str | None = None, + ax: Any = None, + show: bool = True, +) -> Any: + """Compare a single cross-section type across multiple elements. + + Parameters + ---------- + client : EPICSClient + Initialised client instance. + elements : sequence of int or str + Elements to compare. + label : str + Cross-section label (e.g. ``"xs_tot"``). + library : str + ``"EEDL"`` or ``"EPDL"``. + logx, logy : bool + Use logarithmic axes. + title : str or None + Plot title. + ax : matplotlib.axes.Axes or None + Existing axes. + show : bool + Call ``plt.show()``. + + Returns + ------- + matplotlib.axes.Axes + """ + plt = _import_matplotlib() + + if ax is None: + fig, ax = plt.subplots(figsize=(8, 5)) + + for elem in elements: + try: + energy, xs = client.get_cross_section(elem, label, library=library) + except (KeyError, Exception): + continue + from pyepics.client import _resolve_element + z, sym = _resolve_element(elem) + ax.plot(energy, xs, label=f"{sym} (Z={z})") + + if logx: + ax.set_xscale("log") + if logy: + ax.set_yscale("log") + + ax.set_xlabel("Energy (eV)") + ax.set_ylabel("Cross Section (barns)") + ax.set_title(title or f"{label} — Element Comparison") + ax.legend(fontsize="small") + ax.grid(True, which="both", alpha=0.3) + + if show: + plt.tight_layout() + plt.show() + return ax + + +def plot_binding_energies( + client: "EPICSClient", + elements: Sequence["ElementID"], + *, + subshell: str | None = None, + title: str | None = None, + ax: Any = None, + show: bool = True, +) -> Any: + """Plot binding energies vs. atomic number. + + Parameters + ---------- + client : EPICSClient + Initialised client instance. + elements : sequence of int or str + Elements to plot. + subshell : str or None + If given, plot only this subshell (e.g. ``"K"``). + Otherwise, plot all subshells with connected lines. + title : str or None + Plot title. + ax : matplotlib.axes.Axes or None + Existing axes. + show : bool + Call ``plt.show()``. + + Returns + ------- + matplotlib.axes.Axes + """ + plt = _import_matplotlib() + + if ax is None: + fig, ax = plt.subplots(figsize=(8, 5)) + + from pyepics.client import _resolve_element + + # Collect data: subshell -> [(Z, BE)] + data: dict[str, list[tuple[int, float]]] = {} + for elem in elements: + ep = client.get_element(elem, libraries=["EADL"]) + if ep.atomic is None: + continue + z = ep.Z + for name, sub in ep.atomic.subshells.items(): + if subshell is not None and name != subshell: + continue + data.setdefault(name, []).append((z, sub.binding_energy_eV)) + + for name, points in sorted(data.items()): + points.sort() + zs = [p[0] for p in points] + bes = [p[1] for p in points] + ax.plot(zs, bes, "o-", label=name, markersize=3) + + ax.set_xlabel("Atomic Number (Z)") + ax.set_ylabel("Binding Energy (eV)") + ax.set_yscale("log") + ax.set_title(title or "Subshell Binding Energies vs. Z") + ax.legend(fontsize="x-small", ncol=3, loc="best") + ax.grid(True, which="both", alpha=0.3) + + if show: + plt.tight_layout() + plt.show() + return ax + + +def plot_shell_binding_energies( + client: "EPICSClient", + element: "ElementID", + *, + title: str | None = None, + ax: Any = None, + show: bool = True, +) -> Any: + """Bar chart of binding energies by subshell for a single element. + + Parameters + ---------- + client : EPICSClient + Initialised client instance. + element : int or str + Element identifier. + title : str or None + Plot title. + ax : matplotlib.axes.Axes or None + Existing axes. + show : bool + Call ``plt.show()``. + + Returns + ------- + matplotlib.axes.Axes + """ + plt = _import_matplotlib() + + ep = client.get_element(element, libraries=["EADL"]) + if ep.atomic is None: + raise ValueError(f"No EADL data for {ep.symbol} (Z={ep.Z}).") + + if ax is None: + fig, ax = plt.subplots(figsize=(10, 4)) + + names = list(ep.atomic.subshells.keys()) + bes = [ep.atomic.subshells[n].binding_energy_eV for n in names] + + ax.bar(names, bes, color="steelblue", edgecolor="navy", alpha=0.8) + ax.set_xlabel("Subshell") + ax.set_ylabel("Binding Energy (eV)") + ax.set_yscale("log") + ax.set_title(title or f"{ep.symbol} (Z={ep.Z}) — Subshell Binding Energies") + ax.tick_params(axis="x", rotation=45) + ax.grid(True, which="both", axis="y", alpha=0.3) + + if show: + plt.tight_layout() + plt.show() + return ax diff --git a/pyepics/readers/eadl.py b/pyepics/readers/eadl.py index edb8fdd..5298720 100644 --- a/pyepics/readers/eadl.py +++ b/pyepics/readers/eadl.py @@ -28,9 +28,8 @@ References ---------- -.. [1] ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2), §28. -.. [2] LLNL Nuclear Data — EPICS 2025. - https://nuclear.llnl.gov/EPICS/ +- ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2), §28. +- LLNL Nuclear Data — EPICS 2025, https://nuclear.llnl.gov/EPICS/ """ from __future__ import annotations diff --git a/pyepics/readers/eedl.py b/pyepics/readers/eedl.py index 04ab66d..21e1a02 100644 --- a/pyepics/readers/eedl.py +++ b/pyepics/readers/eedl.py @@ -31,9 +31,8 @@ References ---------- -.. [1] ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2). -.. [2] LLNL Nuclear Data — EPICS 2025. - https://nuclear.llnl.gov/EPICS/ +- ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2). +- LLNL Nuclear Data — EPICS 2025, https://nuclear.llnl.gov/EPICS/ """ from __future__ import annotations diff --git a/pyepics/readers/epdl.py b/pyepics/readers/epdl.py index 677137a..2e8c39d 100644 --- a/pyepics/readers/epdl.py +++ b/pyepics/readers/epdl.py @@ -25,9 +25,8 @@ References ---------- -.. [1] ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2). -.. [2] LLNL Nuclear Data — EPICS 2025. - https://nuclear.llnl.gov/EPICS/ +- ENDF-6 Formats Manual (ENDF-102, BNL-90365-2009 Rev. 2). +- LLNL Nuclear Data — EPICS 2025, https://nuclear.llnl.gov/EPICS/ """ from __future__ import annotations diff --git a/pyepics/utils/constants.py b/pyepics/utils/constants.py index 3f0db04..ca9cd02 100644 --- a/pyepics/utils/constants.py +++ b/pyepics/utils/constants.py @@ -8,14 +8,15 @@ """ Physical constants and data-mapping tables used across PyEPICS -All constants are sourced from NIST CODATA 2018 [1]_. Mapping +All constants are sourced from NIST CODATA 2018 [constants-1]_. Mapping dictionaries use ``(MF, MT)`` integer tuples as keys so that look-ups from ENDF section identifiers are O(1). References ---------- -.. [1] NIST, "The 2018 CODATA Recommended Values of the Fundamental - Physical Constants", https://physics.nist.gov/cuu/pdf/wallet_2018.pdf +.. [constants-1] NIST, "The 2018 CODATA Recommended Values of the + Fundamental Physical Constants", + https://physics.nist.gov/cuu/pdf/wallet_2018.pdf """ from __future__ import annotations diff --git a/pyepics/utils/parsing.py b/pyepics/utils/parsing.py index d8bcba9..012d14d 100644 --- a/pyepics/utils/parsing.py +++ b/pyepics/utils/parsing.py @@ -28,7 +28,7 @@ References ---------- -.. [1] ENDF-6 Formats Manual (ENDF-102), BNL-90365-2009 Rev. 2, §0.6. +- ENDF-6 Formats Manual (ENDF-102), BNL-90365-2009 Rev. 2, §0.6. """ from __future__ import annotations diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..838c2ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyepics-data" +version = "0.1.0" +description = "Python library for reading and converting EPICS (Electron Photon Interaction Cross Sections) nuclear data" +readme = "README.md" +license = {text = "BSD-3-Clause"} +requires-python = ">=3.11" +authors = [ + {name = "Melek Derman", email = "melekderman@example.com"}, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Physics", + "Typing :: Typed", +] +keywords = ["nuclear data", "EEDL", "EPDL", "EADL", "EPICS", "ENDF", "HDF5", "Monte Carlo"] + +dependencies = [ + "numpy>=1.24", + "h5py>=3.8", + "endf>=0.1", +] + +[project.optional-dependencies] +download = [ + "requests>=2.28", + "beautifulsoup4>=4.11", +] +pandas = [ + "pandas>=2.0", +] +plot = [ + "matplotlib>=3.7", +] +all = [ + "pyepics-data[download,pandas,plot]", +] +dev = [ + "pyepics-data[all]", + "pytest>=7.0", + "ruff>=0.4", + "sphinx>=7.0", + "sphinx-rtd-theme>=2.0", + "myst-parser>=3.0", +] + +[project.urls] +Homepage = "https://github.com/melekderman/PyEPICS" +Documentation = "https://pyepics.readthedocs.io" +Repository = "https://github.com/melekderman/PyEPICS" +Issues = "https://github.com/melekderman/PyEPICS/issues" + +[project.scripts] +pyepics = "pyepics.cli:main" + +[tool.setuptools.packages.find] +include = ["pyepics*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-v --tb=short" +filterwarnings = [ + "ignore::DeprecationWarning:pyepics.pyeedl_compat", +] + +[tool.ruff] +target-version = "py311" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B"] +ignore = ["E501"] diff --git a/tests/generate_report.py b/tests/generate_report.py index 35a5d37..c2373a1 100644 --- a/tests/generate_report.py +++ b/tests/generate_report.py @@ -629,7 +629,7 @@ def section_data_dictionaries(pdf, ctx): lib_dir = endf_dir / lib_name if not lib_dir.exists(): continue - for fpath in sorted(lib_dir.glob("*.endf"))[:5]: # sample first 5 + for fpath in sorted(lib_dir.glob("*.endf")): # scan all files try: import endf tape = endf.Material(fpath) diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..31f5736 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +# ----------------------------------------------------------------------------- +# Copyright (c) 2026 Melek Derman +# +# SPDX-License-Identifier: BSD-3-Clause +# ----------------------------------------------------------------------------- + +""" +Tests for the high-level client API (EPICSClient, ElementProperties) +and the optional plotting module. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from pyepics.client import ( + EPICSClient, + ElementProperties, + _resolve_element, +) +from pyepics.exceptions import ValidationError +from pyepics.models.records import ( + AverageEnergyLoss, + CrossSectionRecord, + DistributionRecord, + EADLDataset, + EEDLDataset, + EPDLDataset, + FormFactorRecord, + SubshellRelaxation, + SubshellTransition, +) + + +# --------------------------------------------------------------------------- +# Helpers — re-use conftest-style synthetic datasets +# --------------------------------------------------------------------------- + + +def _make_eedl(z: int = 26, symbol: str = "Fe") -> EEDLDataset: + energy = np.array([10.0, 100.0, 1000.0, 10000.0], dtype="f8") + xs_vals = np.array([1.0e-20, 5.0e-21, 1.0e-21, 2.0e-22], dtype="f8") + return EEDLDataset( + Z=z, + symbol=symbol, + atomic_weight_ratio=55.845, + ZA=z * 1000.0, + cross_sections={ + "xs_tot": CrossSectionRecord("xs_tot", energy, xs_vals), + "xs_el": CrossSectionRecord("xs_el", energy, xs_vals * 0.5), + }, + distributions={ + "ang_lge": DistributionRecord( + "ang_lge", + np.array([100.0, 100.0], dtype="f8"), + np.array([-1.0, 0.0], dtype="f8"), + np.array([0.3, 0.7], dtype="f8"), + ), + }, + average_energy_losses={ + "loss_exc": AverageEnergyLoss( + "loss_exc", + energy, + np.array([5.0, 10.0, 20.0, 50.0], dtype="f8"), + ), + }, + ) + + +def _make_epdl(z: int = 26, symbol: str = "Fe") -> EPDLDataset: + energy = np.array([100.0, 1000.0, 10000.0, 100000.0], dtype="f8") + xs_vals = np.array([5e-22, 3e-22, 1e-22, 5e-23], dtype="f8") + return EPDLDataset( + Z=z, + symbol=symbol, + atomic_weight_ratio=55.845, + ZA=z * 1000.0, + cross_sections={ + "xs_tot": CrossSectionRecord("xs_tot", energy, xs_vals), + }, + form_factors={ + "ff_coherent": FormFactorRecord( + "ff_coherent", + np.array([0.0, 1.0, 2.0], dtype="f8"), + np.array([1.0, 0.8, 0.5], dtype="f8"), + ), + }, + ) + + +def _make_eadl(z: int = 26, symbol: str = "Fe") -> EADLDataset: + return EADLDataset( + Z=z, + symbol=symbol, + atomic_weight_ratio=55.845, + ZA=z * 1000.0, + n_subshells=2, + subshells={ + "K": SubshellRelaxation( + designator=1, + name="K", + binding_energy_eV=7112.0, + n_electrons=2.0, + transitions=[ + SubshellTransition( + origin_designator=2, + origin_label="L1", + secondary_designator=0, + secondary_label="radiative", + energy_eV=6391.0, + probability=0.342, + is_radiative=True, + ), + ], + ), + "L1": SubshellRelaxation( + designator=2, + name="L1", + binding_energy_eV=844.6, + n_electrons=2.0, + transitions=[], + ), + }, + ) + + +# =================================================================== +# Tests: _resolve_element +# =================================================================== + + +class TestResolveElement: + """Tests for the element-identifier resolver.""" + + def test_resolve_by_z(self): + z, sym = _resolve_element(26) + assert z == 26 + assert sym == "Fe" + + def test_resolve_by_symbol(self): + z, sym = _resolve_element("Fe") + assert z == 26 + assert sym == "Fe" + + def test_resolve_by_symbol_case_insensitive(self): + z, sym = _resolve_element("fe") + assert z == 26 + z2, sym2 = _resolve_element("FE") + assert z2 == 26 + + def test_resolve_by_name(self): + z, sym = _resolve_element("Iron") + assert z == 26 + assert sym == "Fe" + + def test_resolve_by_name_case_insensitive(self): + z, sym = _resolve_element("iron") + assert z == 26 + + def test_resolve_hydrogen(self): + z, sym = _resolve_element(1) + assert z == 1 and sym == "H" + + def test_resolve_oganesson(self): + z, sym = _resolve_element(118) + assert z == 118 and sym == "Og" + + def test_invalid_z_zero(self): + with pytest.raises(ValidationError, match="outside the valid range"): + _resolve_element(0) + + def test_invalid_z_negative(self): + with pytest.raises(ValidationError, match="outside the valid range"): + _resolve_element(-1) + + def test_invalid_z_over_118(self): + with pytest.raises(ValidationError, match="outside the valid range"): + _resolve_element(119) + + def test_invalid_string(self): + with pytest.raises(ValidationError, match="Unknown element"): + _resolve_element("Unobtanium") + + def test_invalid_type(self): + with pytest.raises(ValidationError, match="int or str"): + _resolve_element(3.14) # type: ignore + + def test_numpy_integer(self): + z, sym = _resolve_element(np.int64(26)) + assert z == 26 and sym == "Fe" + + +# =================================================================== +# Tests: ElementProperties +# =================================================================== + + +class TestElementProperties: + """Tests for the ElementProperties container.""" + + def test_basic_attributes(self): + ep = ElementProperties( + 26, "Fe", "Iron", + electron=_make_eedl(), + photon=_make_epdl(), + atomic=_make_eadl(), + ) + assert ep.Z == 26 + assert ep.symbol == "Fe" + assert ep.name == "Iron" + + def test_binding_energies(self): + ep = ElementProperties(26, "Fe", "Iron", atomic=_make_eadl()) + be = ep.binding_energies + assert be["K"] == 7112.0 + assert be["L1"] == 844.6 + + def test_binding_energies_no_atomic(self): + ep = ElementProperties(26, "Fe", "Iron") + assert ep.binding_energies == {} + + def test_cross_section_labels(self): + ep = ElementProperties(26, "Fe", "Iron", electron=_make_eedl()) + assert "xs_tot" in ep.electron_cross_section_labels + assert "xs_el" in ep.electron_cross_section_labels + + def test_photon_cross_section_labels(self): + ep = ElementProperties(26, "Fe", "Iron", photon=_make_epdl()) + assert "xs_tot" in ep.photon_cross_section_labels + + def test_subshells(self): + ep = ElementProperties(26, "Fe", "Iron", atomic=_make_eadl()) + assert ep.subshells == ["K", "L1"] + assert ep.n_subshells == 2 + + def test_no_libraries(self): + ep = ElementProperties(1, "H", "Hydrogen") + assert ep.electron is None + assert ep.photon is None + assert ep.atomic is None + assert ep.subshells == [] + assert ep.n_subshells == 0 + assert ep.electron_cross_section_labels == [] + assert ep.photon_cross_section_labels == [] + + def test_to_dict(self): + ep = ElementProperties( + 26, "Fe", "Iron", + electron=_make_eedl(), + atomic=_make_eadl(), + ) + d = ep.to_dict() + assert d["Z"] == 26 + assert d["symbol"] == "Fe" + assert d["name"] == "Iron" + assert "K" in d["binding_energies"] + assert "xs_tot" in d["electron_cross_sections"] + assert "atomic_weight_ratio" in d + + def test_repr(self): + ep = ElementProperties(26, "Fe", "Iron", electron=_make_eedl()) + r = repr(ep) + assert "Fe" in r + assert "EEDL" in r + assert "Z=26" in r + + def test_getitem(self): + ep = ElementProperties(26, "Fe", "Iron", electron=_make_eedl()) + assert ep["Z"] == 26 + assert ep["symbol"] == "Fe" + + def test_contains(self): + ep = ElementProperties(26, "Fe", "Iron", electron=_make_eedl()) + assert "Z" in ep + assert "nonexistent" not in ep + + +# =================================================================== +# Tests: EPICSClient +# =================================================================== + + +class TestEPICSClient: + """Tests for EPICSClient with mocked file I/O.""" + + @pytest.fixture + def mock_client(self, tmp_path): + """Client with pre-populated cache (no real files needed).""" + client = EPICSClient(tmp_path) + # Pre-fill cache + client._cache[26] = (_make_eedl(26, "Fe"), _make_epdl(26, "Fe"), _make_eadl(26, "Fe")) + client._cache[29] = (_make_eedl(29, "Cu"), _make_epdl(29, "Cu"), _make_eadl(29, "Cu")) + client._cache[79] = (_make_eedl(79, "Au"), _make_epdl(79, "Au"), _make_eadl(79, "Au")) + return client + + def test_get_element_by_symbol(self, mock_client): + ep = mock_client.get_element("Fe") + assert ep.Z == 26 + assert ep.symbol == "Fe" + assert ep.name == "Iron" + + def test_get_element_by_z(self, mock_client): + ep = mock_client.get_element(26) + assert ep.symbol == "Fe" + + def test_get_element_by_name(self, mock_client): + ep = mock_client.get_element("Iron") + assert ep.Z == 26 + + def test_get_properties(self, mock_client): + d = mock_client.get_properties("Fe") + assert isinstance(d, dict) + assert d["Z"] == 26 + assert d["symbol"] == "Fe" + + def test_compare(self, mock_client): + rows = mock_client.compare(["Fe", "Cu", "Au"]) + assert len(rows) == 3 + symbols = [r["symbol"] for r in rows] + assert symbols == ["Fe", "Cu", "Au"] + + def test_compare_with_properties_filter(self, mock_client): + rows = mock_client.compare( + ["Fe", "Cu"], properties=["Z", "symbol"] + ) + assert all(set(r.keys()) == {"Z", "symbol"} for r in rows) + + def test_get_cross_section(self, mock_client): + energy, xs = mock_client.get_cross_section("Fe", "xs_tot") + assert isinstance(energy, np.ndarray) + assert isinstance(xs, np.ndarray) + assert len(energy) == len(xs) + + def test_get_cross_section_missing_label(self, mock_client): + with pytest.raises(KeyError, match="xs_nonexistent"): + mock_client.get_cross_section("Fe", "xs_nonexistent") + + def test_get_cross_section_bad_library(self, mock_client): + with pytest.raises(ValidationError, match="not"): + mock_client.get_cross_section("Fe", "xs_tot", library="EADL") + + def test_clear_cache(self, mock_client): + assert len(mock_client._cache) > 0 + mock_client.clear_cache() + assert len(mock_client._cache) == 0 + + def test_invalid_element(self, mock_client): + with pytest.raises(ValidationError): + mock_client.get_element("Unobtanium") + + def test_available_elements_no_dir(self, tmp_path): + client = EPICSClient(tmp_path) + assert client.available_elements == [] + + def test_available_elements_with_files(self, tmp_path): + eedl_dir = tmp_path / "eedl" + eedl_dir.mkdir() + (eedl_dir / "EEDL.ZA001000.endf").write_text("dummy") + (eedl_dir / "EEDL.ZA026000.endf").write_text("dummy") + client = EPICSClient(tmp_path) + available = client.available_elements + assert 1 in available + assert 26 in available + + +# =================================================================== +# Tests: compare_df and binding_energy_table (pandas-dependent) +# =================================================================== + + +class TestPandasIntegration: + """Tests that require pandas.""" + + @pytest.fixture + def mock_client(self, tmp_path): + client = EPICSClient(tmp_path) + for z, sym in [(26, "Fe"), (29, "Cu")]: + client._cache[z] = ( + _make_eedl(z, sym), + _make_epdl(z, sym), + _make_eadl(z, sym), + ) + return client + + def test_compare_df(self, mock_client): + pd = pytest.importorskip("pandas") + df = mock_client.compare_df(["Fe", "Cu"]) + assert len(df) == 2 + assert list(df["symbol"]) == ["Fe", "Cu"] + + def test_compare_df_with_properties(self, mock_client): + pd = pytest.importorskip("pandas") + df = mock_client.compare_df(["Fe", "Cu"], properties=["Z", "symbol"]) + assert set(df.columns) == {"Z", "symbol"} + + def test_binding_energy_table(self, mock_client): + pd = pytest.importorskip("pandas") + df = mock_client.binding_energy_table(["Fe", "Cu"]) + assert "K" in df.columns + assert "L1" in df.columns + assert df.loc["Fe", "K"] == 7112.0 + + +# =================================================================== +# Tests: plotting module +# =================================================================== + + +class TestPlotting: + """Tests for pyepics.plotting functions (with matplotlib mocked).""" + + @pytest.fixture + def mock_client(self, tmp_path): + client = EPICSClient(tmp_path) + for z, sym in [(26, "Fe"), (29, "Cu")]: + client._cache[z] = ( + _make_eedl(z, sym), + _make_epdl(z, sym), + _make_eadl(z, sym), + ) + return client + + def test_plot_cross_sections_import_error(self, mock_client): + """Verify helpful error when matplotlib missing.""" + from pyepics import plotting + + with patch.dict("sys.modules", {"matplotlib": None, "matplotlib.pyplot": None}): + with pytest.raises(ImportError, match="matplotlib"): + plotting.plot_cross_sections(mock_client, "Fe", show=False) + + def test_plot_cross_sections(self, mock_client): + pytest.importorskip("matplotlib") + from pyepics.plotting import plot_cross_sections + + ax = plot_cross_sections(mock_client, "Fe", show=False) + assert ax is not None + + def test_compare_cross_sections(self, mock_client): + pytest.importorskip("matplotlib") + from pyepics.plotting import compare_cross_sections + + ax = compare_cross_sections( + mock_client, ["Fe", "Cu"], "xs_tot", show=False + ) + assert ax is not None + + def test_plot_binding_energies(self, mock_client): + pytest.importorskip("matplotlib") + from pyepics.plotting import plot_binding_energies + + ax = plot_binding_energies( + mock_client, ["Fe", "Cu"], show=False + ) + assert ax is not None + + def test_plot_binding_energies_single_subshell(self, mock_client): + pytest.importorskip("matplotlib") + from pyepics.plotting import plot_binding_energies + + ax = plot_binding_energies( + mock_client, ["Fe", "Cu"], subshell="K", show=False + ) + assert ax is not None + + def test_plot_shell_binding_energies(self, mock_client): + pytest.importorskip("matplotlib") + from pyepics.plotting import plot_shell_binding_energies + + ax = plot_shell_binding_energies(mock_client, "Fe", show=False) + assert ax is not None + + def test_plot_shell_no_eadl(self, mock_client): + pytest.importorskip("matplotlib") + from pyepics.plotting import plot_shell_binding_energies + + # Override cache with no EADL + mock_client._cache[1] = (_make_eedl(1, "H"), None, None) + with pytest.raises(ValueError, match="No EADL"): + plot_shell_binding_energies(mock_client, "H", show=False) From 17286c7a0cf1b19683fe18daf4b1bc7a34ebce38 Mon Sep 17 00:00:00 2001 From: Melek Derman <48313913+melekderman@users.noreply.github.com> Date: Sun, 15 Feb 2026 02:19:40 -0800 Subject: [PATCH 3/5] add client features --- .github/workflows/workflow.yml | 29 +++++++++++++ README.md | 2 +- docs/data_sources.rst | 2 +- pyepics/__init__.py | 18 ++++----- pyepics/client.py | 11 +++-- pyepics/converters/__init__.py | 2 +- pyepics/converters/hdf5.py | 14 +++---- pyepics/converters/mcdc_hdf5.py | 6 +-- pyepics/converters/raw_hdf5.py | 6 +-- pyepics/models/__init__.py | 8 ++-- pyepics/models/records.py | 1 - pyepics/plotting.py | 24 +++++------ pyepics/pyeedl_compat.py | 65 +++++++++++++++++------------- pyepics/readers/__init__.py | 2 +- pyepics/readers/base.py | 5 +-- pyepics/readers/eedl.py | 16 ++++---- pyepics/utils/constants.py | 15 +++++-- pyepics/utils/parsing.py | 2 +- tests/generate_report.py | 12 +++--- tests/regression_tests.ipynb | 16 ++++---- tests/test_mapping_completeness.py | 18 ++++----- 21 files changed, 160 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/workflow.yml diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml new file mode 100644 index 0000000..922b498 --- /dev/null +++ b/.github/workflows/workflow.yml @@ -0,0 +1,29 @@ +name: Publish to PyPI + +on: + release: + types: [published] + +jobs: + build-n-publish: + name: Build and publish to PyPI + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install build dependencies + run: python -m pip install build + + - name: Build binary wheel and source tarball + run: python -m build + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 \ No newline at end of file diff --git a/README.md b/README.md index 1a7c123..cde2a16 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,7 @@ python -m pytest tests/ -v A `pyeedl_compat` shim re-exports legacy API symbols for backward compatibility: ```python -from pyepics.pyeedl_compat import PERIODIC_TABLE, float_endf, SUBSHELL_LABELS +from pyepics.pyeedl_compat import PERIODIC_TABLE, float_endf, ELECTRON_SUBSHELL_LABELS ``` ## Data Sources diff --git a/docs/data_sources.rst b/docs/data_sources.rst index 070ca9e..42fba59 100644 --- a/docs/data_sources.rst +++ b/docs/data_sources.rst @@ -89,7 +89,7 @@ keeps three mapping dictionaries in ``pyepics/utils/constants.py``: * - Dictionary - Library - Purpose - * - ``MF_MT`` / ``SECTIONS_ABBREVS`` + * - ``ELECTRON_MF_MT`` / ``ELECTRON_SECTIONS_ABBREVS`` - EEDL - Electron cross-section & distribution sections * - ``PHOTON_MF_MT`` / ``PHOTON_SECTIONS_ABBREVS`` diff --git a/pyepics/__init__.py b/pyepics/__init__.py index 1604f24..49f581f 100644 --- a/pyepics/__init__.py +++ b/pyepics/__init__.py @@ -51,23 +51,23 @@ __version__ = "0.1.0" __author__ = "Melek Derman" -from pyepics.readers.eedl import EEDLReader -from pyepics.readers.eadl import EADLReader -from pyepics.readers.epdl import EPDLReader +from pyepics.client import ElementProperties, EPICSClient from pyepics.converters.hdf5 import ( convert_dataset_to_hdf5, - create_raw_hdf5, create_mcdc_hdf5, + create_raw_hdf5, ) -from pyepics.client import EPICSClient, ElementProperties from pyepics.exceptions import ( - PyEPICSError, - ParseError, - ValidationError, - FileFormatError, ConversionError, DownloadError, + FileFormatError, + ParseError, + PyEPICSError, + ValidationError, ) +from pyepics.readers.eadl import EADLReader +from pyepics.readers.eedl import EEDLReader +from pyepics.readers.epdl import EPDLReader __all__ = [ # Version diff --git a/pyepics/client.py b/pyepics/client.py index efc5ce8..9b3522c 100644 --- a/pyepics/client.py +++ b/pyepics/client.py @@ -25,9 +25,9 @@ from __future__ import annotations import logging -import os +from collections.abc import Sequence from pathlib import Path -from typing import Any, Sequence, Union +from typing import Any import numpy as np @@ -45,7 +45,7 @@ logger = logging.getLogger(__name__) # Type alias for element identifiers -ElementID = Union[int, str] +ElementID = int | str # --------------------------------------------------------------------------- # Internal helpers @@ -161,6 +161,7 @@ def __init__( photon: EPDLDataset | None = None, atomic: EADLDataset | None = None, ) -> None: + """Initialise an ElementProperties container.""" self.Z = z self.symbol = symbol self.name = name @@ -244,6 +245,7 @@ def to_dict(self) -> dict[str, Any]: return d def __repr__(self) -> str: + """Return a developer-friendly string representation.""" libs = [] if self.electron: libs.append("EEDL") @@ -257,9 +259,11 @@ def __repr__(self) -> str: ) def __getitem__(self, key: str) -> Any: + """Allow dict-style access to :meth:`to_dict` keys.""" return self.to_dict()[key] def __contains__(self, key: str) -> bool: + """Support ``key in props`` membership tests.""" return key in self.to_dict() @@ -288,6 +292,7 @@ class EPICSClient: """ def __init__(self, data_dir: str | Path = "data/endf") -> None: + """Initialise the client with the path to ENDF data.""" self._data_dir = Path(data_dir) self._eedl_reader = EEDLReader() self._epdl_reader = EPDLReader() diff --git a/pyepics/converters/__init__.py b/pyepics/converters/__init__.py index e83e385..7909843 100644 --- a/pyepics/converters/__init__.py +++ b/pyepics/converters/__init__.py @@ -22,8 +22,8 @@ from pyepics.converters.hdf5 import ( convert_dataset_to_hdf5, - create_raw_hdf5, create_mcdc_hdf5, + create_raw_hdf5, ) __all__ = ["convert_dataset_to_hdf5", "create_raw_hdf5", "create_mcdc_hdf5"] diff --git a/pyepics/converters/hdf5.py b/pyepics/converters/hdf5.py index 63cde81..587df40 100644 --- a/pyepics/converters/hdf5.py +++ b/pyepics/converters/hdf5.py @@ -88,7 +88,7 @@ EPDLDataset, ) from pyepics.readers.base import DatasetModel -from pyepics.utils.constants import SUBSHELL_LABELS +from pyepics.utils.constants import ELECTRON_SUBSHELL_LABELS from pyepics.utils.parsing import ( build_pdf, linear_interpolation, @@ -255,7 +255,7 @@ def interp(key: str) -> np.ndarray: _create_xs_dataset(ion_grp, "xs", xs_ion_total, "barns") subs_grp = ion_grp.create_group("subshells") - for mt, shell_label in SUBSHELL_LABELS.items(): + for _mt, shell_label in ELECTRON_SUBSHELL_LABELS.items(): xs_key = f"xs_{shell_label}" spec_key = f"spec_{shell_label}" @@ -337,7 +337,7 @@ def interp(key: str) -> np.ndarray: pe_grp = root.create_group("photoelectric") _create_xs_dataset(pe_grp, "xs", interp("xs_photoelectric"), "barns") pe_subs = pe_grp.create_group("subshells") - for mt, shell_label in SUBSHELL_LABELS.items(): + for _mt, shell_label in ELECTRON_SUBSHELL_LABELS.items(): key = f"xs_pe_{shell_label}" if key not in xs: continue @@ -537,8 +537,8 @@ def convert_dataset_to_hdf5( ) # Select reader - from pyepics.readers.eedl import EEDLReader from pyepics.readers.eadl import EADLReader + from pyepics.readers.eedl import EEDLReader from pyepics.readers.epdl import EPDLReader reader_map = { @@ -581,8 +581,8 @@ def convert_dataset_to_hdf5( def _get_reader(dataset_type: str): """Return the correct reader class for a dataset type.""" - from pyepics.readers.eedl import EEDLReader from pyepics.readers.eadl import EADLReader + from pyepics.readers.eedl import EEDLReader from pyepics.readers.epdl import EPDLReader return {"EEDL": EEDLReader, "EADL": EADLReader, "EPDL": EPDLReader}[dataset_type] @@ -619,9 +619,9 @@ def create_raw_hdf5( >>> create_raw_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/raw/electron/Fe.h5") """ from pyepics.converters.raw_hdf5 import ( + write_raw_eadl, write_raw_eedl, write_raw_epdl, - write_raw_eadl, ) writers = {"EEDL": write_raw_eedl, "EPDL": write_raw_epdl, "EADL": write_raw_eadl} @@ -681,9 +681,9 @@ def create_mcdc_hdf5( >>> create_mcdc_hdf5("EEDL", "data/endf/eedl/EEDL.ZA026000.endf", "data/mcdc/electron/Fe.h5") """ from pyepics.converters.mcdc_hdf5 import ( + write_mcdc_eadl, write_mcdc_eedl, write_mcdc_epdl, - write_mcdc_eadl, ) writers = {"EEDL": write_mcdc_eedl, "EPDL": write_mcdc_epdl, "EADL": write_mcdc_eadl} diff --git a/pyepics/converters/mcdc_hdf5.py b/pyepics/converters/mcdc_hdf5.py index 6427b65..f3bbfcc 100644 --- a/pyepics/converters/mcdc_hdf5.py +++ b/pyepics/converters/mcdc_hdf5.py @@ -56,7 +56,7 @@ EEDLDataset, EPDLDataset, ) -from pyepics.utils.constants import SUBSHELL_LABELS +from pyepics.utils.constants import ELECTRON_SUBSHELL_LABELS from pyepics.utils.parsing import ( build_pdf, linear_interpolation, @@ -199,7 +199,7 @@ def interp(key: str) -> np.ndarray: _create_xs_dataset(ion_grp, "xs", xs_ion_total, "barns") subs_grp = ion_grp.create_group("subshells") - for mt, shell_label in SUBSHELL_LABELS.items(): + for _mt, shell_label in ELECTRON_SUBSHELL_LABELS.items(): xs_key = f"xs_{shell_label}" spec_key = f"spec_{shell_label}" if xs_key not in xs: @@ -292,7 +292,7 @@ def interp(key: str) -> np.ndarray: pe_grp = root.create_group("photoelectric") _create_xs_dataset(pe_grp, "xs", interp("xs_photoelectric"), "barns") pe_subs = pe_grp.create_group("subshells") - for mt, shell_label in SUBSHELL_LABELS.items(): + for _mt, shell_label in ELECTRON_SUBSHELL_LABELS.items(): key = f"xs_pe_{shell_label}" if key not in xs: continue diff --git a/pyepics/converters/raw_hdf5.py b/pyepics/converters/raw_hdf5.py index ed1e5ae..4f08c34 100644 --- a/pyepics/converters/raw_hdf5.py +++ b/pyepics/converters/raw_hdf5.py @@ -79,7 +79,7 @@ EPDLDataset, FormFactorRecord, ) -from pyepics.utils.constants import SUBSHELL_LABELS +from pyepics.utils.constants import ELECTRON_SUBSHELL_LABELS logger = logging.getLogger(__name__) @@ -187,7 +187,7 @@ def write_raw_eedl(h5f: h5py.File, dataset: EEDLDataset) -> None: if "xs_ion" in xs: _write_xs_record(ig.create_group("cross_section/total"), xs["xs_ion"]) - for mt, shell_label in SUBSHELL_LABELS.items(): + for _mt, shell_label in ELECTRON_SUBSHELL_LABELS.items(): xs_key = f"xs_{shell_label}" spec_key = f"spec_{shell_label}" if xs_key not in xs: @@ -243,7 +243,7 @@ def write_raw_epdl(h5f: h5py.File, dataset: EPDLDataset) -> None: pg = h5f.create_group("photoelectric") if "xs_photoelectric" in xs: _write_xs_record(pg.create_group("cross_section/total"), xs["xs_photoelectric"]) - for mt, shell_label in SUBSHELL_LABELS.items(): + for _mt, shell_label in ELECTRON_SUBSHELL_LABELS.items(): key = f"xs_pe_{shell_label}" if key in xs: _write_xs_record(pg.create_group(f"cross_section/{shell_label}"), xs[key]) diff --git a/pyepics/models/__init__.py b/pyepics/models/__init__.py index 404a859..d9acaf2 100644 --- a/pyepics/models/__init__.py +++ b/pyepics/models/__init__.py @@ -18,12 +18,12 @@ from pyepics.models.records import ( CrossSectionRecord, DistributionRecord, - FormFactorRecord, - SubshellTransition, - SubshellRelaxation, + EADLDataset, EEDLDataset, EPDLDataset, - EADLDataset, + FormFactorRecord, + SubshellRelaxation, + SubshellTransition, ) __all__ = [ diff --git a/pyepics/models/records.py b/pyepics/models/records.py index b1f7c63..d127e6b 100644 --- a/pyepics/models/records.py +++ b/pyepics/models/records.py @@ -40,7 +40,6 @@ import numpy as np - # --------------------------------------------------------------------------- # Atomic-level building blocks # --------------------------------------------------------------------------- diff --git a/pyepics/plotting.py b/pyepics/plotting.py index 7e69b3b..2b5aa2f 100644 --- a/pyepics/plotting.py +++ b/pyepics/plotting.py @@ -20,12 +20,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Sequence - -import numpy as np +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from pyepics.client import EPICSClient, ElementID + from pyepics.client import ElementID, EPICSClient def _import_matplotlib(): @@ -41,8 +40,8 @@ def _import_matplotlib(): def plot_cross_sections( - client: "EPICSClient", - element: "ElementID", + client: EPICSClient, + element: ElementID, *, labels: Sequence[str] | None = None, library: str = "EEDL", @@ -119,8 +118,8 @@ def plot_cross_sections( def compare_cross_sections( - client: "EPICSClient", - elements: Sequence["ElementID"], + client: EPICSClient, + elements: Sequence[ElementID], label: str, *, library: str = "EEDL", @@ -187,8 +186,8 @@ def compare_cross_sections( def plot_binding_energies( - client: "EPICSClient", - elements: Sequence["ElementID"], + client: EPICSClient, + elements: Sequence[ElementID], *, subshell: str | None = None, title: str | None = None, @@ -222,7 +221,6 @@ def plot_binding_energies( if ax is None: fig, ax = plt.subplots(figsize=(8, 5)) - from pyepics.client import _resolve_element # Collect data: subshell -> [(Z, BE)] data: dict[str, list[tuple[int, float]]] = {} @@ -256,8 +254,8 @@ def plot_binding_energies( def plot_shell_binding_energies( - client: "EPICSClient", - element: "ElementID", + client: EPICSClient, + element: ElementID, *, title: str | None = None, ax: Any = None, diff --git a/pyepics/pyeedl_compat.py b/pyepics/pyeedl_compat.py index e21a0c6..7bc19c1 100644 --- a/pyepics/pyeedl_compat.py +++ b/pyepics/pyeedl_compat.py @@ -39,52 +39,55 @@ __version__ = "0.1.0" # ── Data constants & mappings ─────────────────────────────────────────── +# ── Converter ────────────────────────────────────────────────────────── +from pyepics.converters.hdf5 import convert_dataset_to_hdf5 # noqa: E402, F401 +from pyepics.readers.eadl import EADLReader # noqa: E402, F401 + +# ── Readers (thin wrappers returning old-style dicts are NOT provided; +# users of the legacy functions should call readers directly) ───────── +from pyepics.readers.eedl import EEDLReader # noqa: E402, F401 +from pyepics.readers.epdl import EPDLReader # noqa: E402, F401 from pyepics.utils.constants import ( # noqa: E402, F401 - PERIODIC_TABLE, - # Electron (EEDL) - MF_MT, - SECTIONS_ABBREVS, - MF23, - MF26, - SUBSHELL_LABELS, - # Photon (EPDL) - PHOTON_MF_MT, - PHOTON_SECTIONS_ABBREVS, - MF27, # Atomic (EADL) ATOMIC_MF_MT, ATOMIC_SECTIONS_ABBREVS, - MF28, - SUBSHELL_DESIGNATORS, - SUBSHELL_DESIGNATORS_INV, + BARN_TO_CM2, + ELECTRON_CHARGE, + ELECTRON_MASS, + # Electron (EEDL) — canonical names + ELECTRON_MF_MT, + ELECTRON_SECTIONS_ABBREVS, + ELECTRON_SUBSHELL_LABELS, # Physical constants FINE_STRUCTURE, - ELECTRON_MASS, - BARN_TO_CM2, + MF23, + MF26, + MF27, + MF28, + # Electron (EEDL) — backward-compatible aliases + MF_MT, + PERIODIC_TABLE, + # Photon (EPDL) + PHOTON_MF_MT, + PHOTON_SECTIONS_ABBREVS, PLANCK_CONSTANT, + SECTIONS_ABBREVS, SPEED_OF_LIGHT, - ELECTRON_CHARGE, + SUBSHELL_DESIGNATORS, + SUBSHELL_DESIGNATORS_INV, + SUBSHELL_LABELS, ) # ── Utility / math functions ─────────────────────────────────────────── from pyepics.utils.parsing import ( # noqa: E402, F401 + build_pdf, float_endf, int_endf, linear_interpolation, - build_pdf, small_angle_eta, small_angle_scattering_cosine, ) -# ── Readers (thin wrappers returning old-style dicts are NOT provided; -# users of the legacy functions should call readers directly) ───────── -from pyepics.readers.eedl import EEDLReader # noqa: E402, F401 -from pyepics.readers.epdl import EPDLReader # noqa: E402, F401 -from pyepics.readers.eadl import EADLReader # noqa: E402, F401 - -# ── Converter ────────────────────────────────────────────────────────── -from pyepics.converters.hdf5 import convert_dataset_to_hdf5 # noqa: E402, F401 - __all__ = [ # Version "__version__", @@ -94,12 +97,16 @@ "EADLReader", # Converter (new API) "convert_dataset_to_hdf5", - # Data mappings - Electron + # Data mappings - Electron (canonical) + "ELECTRON_MF_MT", + "ELECTRON_SECTIONS_ABBREVS", + "ELECTRON_SUBSHELL_LABELS", + # Data mappings - Electron (backward-compatible aliases) "MF_MT", "SECTIONS_ABBREVS", + "SUBSHELL_LABELS", "MF23", "MF26", - "SUBSHELL_LABELS", # Data mappings - Photon "PHOTON_MF_MT", "PHOTON_SECTIONS_ABBREVS", diff --git a/pyepics/readers/__init__.py b/pyepics/readers/__init__.py index 529abed..5d0f72d 100644 --- a/pyepics/readers/__init__.py +++ b/pyepics/readers/__init__.py @@ -20,8 +20,8 @@ from __future__ import annotations -from pyepics.readers.eedl import EEDLReader from pyepics.readers.eadl import EADLReader +from pyepics.readers.eedl import EEDLReader from pyepics.readers.epdl import EPDLReader __all__ = ["EEDLReader", "EADLReader", "EPDLReader"] diff --git a/pyepics/readers/base.py b/pyepics/readers/base.py index 6e1e076..4e6615c 100644 --- a/pyepics/readers/base.py +++ b/pyepics/readers/base.py @@ -18,13 +18,12 @@ import logging from abc import ABC, abstractmethod from pathlib import Path -from typing import Union -from pyepics.models.records import EEDLDataset, EPDLDataset, EADLDataset +from pyepics.models.records import EADLDataset, EEDLDataset, EPDLDataset logger = logging.getLogger(__name__) -DatasetModel = Union[EEDLDataset, EPDLDataset, EADLDataset] +DatasetModel = EEDLDataset | EPDLDataset | EADLDataset """Type alias for the union of all dataset model types.""" diff --git a/pyepics/readers/eedl.py b/pyepics/readers/eedl.py index 21e1a02..df97e8d 100644 --- a/pyepics/readers/eedl.py +++ b/pyepics/readers/eedl.py @@ -59,9 +59,9 @@ ) from pyepics.readers.base import BaseReader from pyepics.utils.constants import ( + ELECTRON_SECTIONS_ABBREVS, + ELECTRON_SUBSHELL_LABELS, PERIODIC_TABLE, - SECTIONS_ABBREVS, - SUBSHELL_LABELS, ) from pyepics.utils.parsing import ( extract_atomic_number_from_path, @@ -173,7 +173,7 @@ def read( bremsstrahlung_spectra: DistributionRecord | None = None # -- MF=23: Cross Sections ---------------------------------------- - for (mf, mt), abbrev in SECTIONS_ABBREVS.items(): + for (mf, mt), abbrev in ELECTRON_SECTIONS_ABBREVS.items(): if mf != 23 or (mf, mt) not in mat.section_data: continue @@ -200,7 +200,7 @@ def read( logger.debug(" MF=23/MT=%d (%s): %d points", mt, abbrev, energy.size) # -- MF=26: Distributions ----------------------------------------- - for (mf, mt), abbrev in SECTIONS_ABBREVS.items(): + for (mf, mt), abbrev in ELECTRON_SECTIONS_ABBREVS.items(): if mf != 26 or (mf, mt) not in mat.section_data: continue @@ -270,7 +270,7 @@ def read( E_out = sub.get("E'", []) b_raw = sub.get("b") b_flat = b_raw.flatten() if b_raw is not None else [] - for eo, bb in zip(E_out, b_flat): + for eo, bb in zip(E_out, b_flat, strict=False): inc_e_arr.append(E_inc[idx]) out_e_arr.append(eo) b_arr.append(float(bb)) @@ -298,7 +298,7 @@ def read( E_out = sub.get("E'", []) b_raw = sub.get("b") b_flat = b_raw.flatten() if b_raw is not None else [] - for eo, bb in zip(E_out, b_flat): + for eo, bb in zip(E_out, b_flat, strict=False): inc_e_arr2.append(E_inc[idx]) out_e_arr2.append(eo) b_arr2.append(float(bb)) @@ -312,8 +312,8 @@ def read( ) # Store binding energy from y_tab if available - if y_tab is not None and mt in SUBSHELL_LABELS: - shell_label = SUBSHELL_LABELS[mt] + if y_tab is not None and mt in ELECTRON_SUBSHELL_LABELS: + shell_label = ELECTRON_SUBSHELL_LABELS[mt] xs_key = f"xs_{shell_label}" if xs_key in cross_sections: # Attach binding energy as first y_tab energy point diff --git a/pyepics/utils/constants.py b/pyepics/utils/constants.py index ca9cd02..d9aa384 100644 --- a/pyepics/utils/constants.py +++ b/pyepics/utils/constants.py @@ -174,7 +174,7 @@ # Subshell mappings # --------------------------------------------------------------------------- -SUBSHELL_LABELS: dict[int, str] = { +ELECTRON_SUBSHELL_LABELS: dict[int, str] = { 534: "K", 535: "L1", 536: "L2", 537: "L3", 538: "M1", 539: "M2", 540: "M3", 541: "M4", 542: "M5", 543: "N1", 544: "N2", 545: "N3", 546: "N4", 547: "N5", @@ -193,6 +193,9 @@ 535–537 to L sub-shells, and so on through the Q shell. """ +SUBSHELL_LABELS = ELECTRON_SUBSHELL_LABELS +"""Backward-compatible alias for :data:`ELECTRON_SUBSHELL_LABELS`.""" + SUBSHELL_DESIGNATORS: dict[int, str] = { 1: "K", 2: "L1", 3: "L2", 4: "L3", @@ -221,7 +224,7 @@ # Electron (EEDL) MF/MT tables # --------------------------------------------------------------------------- -MF_MT: dict[tuple[int, int], str] = { +ELECTRON_MF_MT: dict[tuple[int, int], str] = { # MF=1 : General Information / Directory # ENDF-6 §1.1 — every material begins with MF=1/MT=451 descriptive data. (1, 451): "General Information / Directory", @@ -317,7 +320,10 @@ } """Human-readable descriptions for every EEDL (MF, MT) section pair.""" -SECTIONS_ABBREVS: dict[tuple[int, int], str] = { +MF_MT = ELECTRON_MF_MT +"""Backward-compatible alias for :data:`ELECTRON_MF_MT`.""" + +ELECTRON_SECTIONS_ABBREVS: dict[tuple[int, int], str] = { # MF=1 general information (1, 451): "general_info", # MF=23 cross sections @@ -358,6 +364,9 @@ } """Short mnemonic abbreviations for each EEDL (MF, MT) section.""" +SECTIONS_ABBREVS = ELECTRON_SECTIONS_ABBREVS +"""Backward-compatible alias for :data:`ELECTRON_SECTIONS_ABBREVS`.""" + # --------------------------------------------------------------------------- # Photon (EPDL) MF/MT tables diff --git a/pyepics/utils/parsing.py b/pyepics/utils/parsing.py index 012d14d..6326bcd 100644 --- a/pyepics/utils/parsing.py +++ b/pyepics/utils/parsing.py @@ -464,7 +464,7 @@ def small_angle_eta(Z: int, energy_eV: np.ndarray) -> np.ndarray: electrons and positrons by atoms, positive ions and molecules. *Computer Physics Communications*, 165(2), 157–190. """ - from pyepics.utils.constants import FINE_STRUCTURE, ELECTRON_MASS + from pyepics.utils.constants import ELECTRON_MASS, FINE_STRUCTURE alpha = FINE_STRUCTURE mec2 = ELECTRON_MASS # MeV diff --git a/tests/generate_report.py b/tests/generate_report.py index c2373a1..79df2d5 100644 --- a/tests/generate_report.py +++ b/tests/generate_report.py @@ -81,17 +81,17 @@ def _lazy_imports(): ) from pyepics.utils.constants import ( PERIODIC_TABLE, - MF_MT, + ELECTRON_MF_MT, PHOTON_MF_MT, ATOMIC_MF_MT, MF23, MF26, MF27, MF28, - SECTIONS_ABBREVS, + ELECTRON_SECTIONS_ABBREVS, PHOTON_SECTIONS_ABBREVS, ATOMIC_SECTIONS_ABBREVS, - SUBSHELL_LABELS, + ELECTRON_SUBSHELL_LABELS, SUBSHELL_DESIGNATORS, FINE_STRUCTURE, ELECTRON_MASS, @@ -641,7 +641,7 @@ def section_data_dictionaries(pdf, ctx): # Compare PyEPICS mapping tables against ENDF contents mapping_checks = [ - ("MF_MT (EEDL)", endf_mf_mt_sets.get("eedl", set()), M.MF_MT), + ("ELECTRON_MF_MT (EEDL)", endf_mf_mt_sets.get("eedl", set()), M.ELECTRON_MF_MT), ("PHOTON_MF_MT (EPDL)", endf_mf_mt_sets.get("epdl", set()), M.PHOTON_MF_MT), ("ATOMIC_MF_MT (EADL)", endf_mf_mt_sets.get("eadl", set()), M.ATOMIC_MF_MT), ] @@ -675,9 +675,9 @@ def section_data_dictionaries(pdf, ctx): # Verify internal dictionary consistency internal_checks = [ ("PERIODIC_TABLE", M.PERIODIC_TABLE, 100), # Z=1..100 - ("SUBSHELL_LABELS", M.SUBSHELL_LABELS, 1), + ("ELECTRON_SUBSHELL_LABELS", M.ELECTRON_SUBSHELL_LABELS, 1), ("SUBSHELL_DESIGNATORS", M.SUBSHELL_DESIGNATORS, 1), - ("SECTIONS_ABBREVS", M.SECTIONS_ABBREVS, 1), + ("ELECTRON_SECTIONS_ABBREVS", M.ELECTRON_SECTIONS_ABBREVS, 1), ] int_lines = [ diff --git a/tests/regression_tests.ipynb b/tests/regression_tests.ipynb index 8d7f202..6206214 100644 --- a/tests/regression_tests.ipynb +++ b/tests/regression_tests.ipynb @@ -49,10 +49,10 @@ "from pyepics.readers.eadl import EADLReader\n", "from pyepics.converters.hdf5 import convert_dataset_to_hdf5\n", "from pyepics.utils.constants import (\n", - " PERIODIC_TABLE, MF_MT, PHOTON_MF_MT, ATOMIC_MF_MT,\n", + " PERIODIC_TABLE, ELECTRON_MF_MT, PHOTON_MF_MT, ATOMIC_MF_MT,\n", " MF23, MF26, MF27, MF28,\n", - " SECTIONS_ABBREVS, PHOTON_SECTIONS_ABBREVS, ATOMIC_SECTIONS_ABBREVS,\n", - " SUBSHELL_LABELS, SUBSHELL_DESIGNATORS,\n", + " ELECTRON_SECTIONS_ABBREVS, PHOTON_SECTIONS_ABBREVS, ATOMIC_SECTIONS_ABBREVS,\n", + " ELECTRON_SUBSHELL_LABELS, SUBSHELL_DESIGNATORS,\n", " FINE_STRUCTURE, ELECTRON_MASS, BARN_TO_CM2,\n", ")\n", "\n", @@ -575,7 +575,7 @@ "source": [ "## 6. Data Structure Inspection: Constants & Mappings\n", "\n", - "Inspects the data dictionaries (MF23, MF26, MF27, MF28, MF_MT, etc.) in PyEPICS's `constants.py` and verifies their completeness." + "Inspects the data dictionaries (MF23, MF26, MF27, MF28, ELECTRON_MF_MT, etc.) in PyEPICS's `constants.py` and verifies their completeness." ] }, { @@ -602,7 +602,7 @@ "source": [ "## 7. Data Dictionary Internal Consistency Check\n", "\n", - "Verifies that PyEPICS data dictionaries (MF_MT, PERIODIC_TABLE, MF23, MF26, etc.) have the expected minimum number of entries and checks for completeness." + "Verifies that PyEPICS data dictionaries (ELECTRON_MF_MT, PERIODIC_TABLE, MF23, MF26, etc.) have the expected minimum number of entries and checks for completeness." ] }, { @@ -614,12 +614,12 @@ "source": [ "# --- Internal consistency: verify all dictionaries have expected entries ---\n", "internal_checks = [\n", - " (\"MF_MT\", MF_MT, 6),\n", - " (\"SECTIONS_ABBREVS\", SECTIONS_ABBREVS, 6),\n", + " (\"ELECTRON_MF_MT\", ELECTRON_MF_MT, 6),\n", + " (\"ELECTRON_SECTIONS_ABBREVS\", ELECTRON_SECTIONS_ABBREVS, 6),\n", " (\"PHOTON_MF_MT\", PHOTON_MF_MT, 1),\n", " (\"PHOTON_SECTIONS_ABBREVS\", PHOTON_SECTIONS_ABBREVS, 1),\n", " (\"ATOMIC_MF_MT\", ATOMIC_MF_MT, 1),\n", - " (\"SUBSHELL_LABELS\", SUBSHELL_LABELS, 1),\n", + " (\"ELECTRON_SUBSHELL_LABELS\", ELECTRON_SUBSHELL_LABELS, 1),\n", " (\"SUBSHELL_DESIGNATORS\", SUBSHELL_DESIGNATORS, 1),\n", " (\"PERIODIC_TABLE\", PERIODIC_TABLE, 100),\n", " (\"MF23\", MF23, 1),\n", diff --git a/tests/test_mapping_completeness.py b/tests/test_mapping_completeness.py index 568d346..abe6135 100644 --- a/tests/test_mapping_completeness.py +++ b/tests/test_mapping_completeness.py @@ -28,13 +28,13 @@ from pyepics.utils.constants import ( ATOMIC_MF_MT, ATOMIC_SECTIONS_ABBREVS, - MF_MT, + ELECTRON_MF_MT, + ELECTRON_SECTIONS_ABBREVS, + ELECTRON_SUBSHELL_LABELS, + PERIODIC_TABLE, PHOTON_MF_MT, PHOTON_SECTIONS_ABBREVS, - SECTIONS_ABBREVS, - SUBSHELL_LABELS, SUBSHELL_DESIGNATORS, - PERIODIC_TABLE, ) # --------------------------------------------------------------------------- @@ -76,7 +76,7 @@ def _collect_endf_mf_mt(lib_name: str) -> set[tuple[int, int]]: @pytest.mark.parametrize( "lib_name, desc_dict, abbrev_dict", [ - ("eedl", MF_MT, SECTIONS_ABBREVS), + ("eedl", ELECTRON_MF_MT, ELECTRON_SECTIONS_ABBREVS), ("epdl", PHOTON_MF_MT, PHOTON_SECTIONS_ABBREVS), ("eadl", ATOMIC_MF_MT, ATOMIC_SECTIONS_ABBREVS), ], @@ -132,18 +132,18 @@ def test_periodic_table_has_all_elements(self): assert len(PERIODIC_TABLE) >= 118 def test_subshell_labels_non_empty(self): - assert len(SUBSHELL_LABELS) >= 1 + assert len(ELECTRON_SUBSHELL_LABELS) >= 1 def test_subshell_designators_non_empty(self): assert len(SUBSHELL_DESIGNATORS) >= 1 def test_sections_abbrevs_non_empty(self): - assert len(SECTIONS_ABBREVS) >= 1 + assert len(ELECTRON_SECTIONS_ABBREVS) >= 1 def test_no_duplicate_abbreviations_eedl(self): """No two EEDL sections share the same abbreviation.""" - vals = list(SECTIONS_ABBREVS.values()) - assert len(vals) == len(set(vals)), "Duplicate abbreviations in SECTIONS_ABBREVS" + vals = list(ELECTRON_SECTIONS_ABBREVS.values()) + assert len(vals) == len(set(vals)), "Duplicate abbreviations in ELECTRON_SECTIONS_ABBREVS" def test_no_duplicate_abbreviations_epdl(self): """No two EPDL sections share the same abbreviation.""" From 731ec911ee3666613de1896ce972e3c48a10b1d1 Mon Sep 17 00:00:00 2001 From: Melek Derman <48313913+melekderman@users.noreply.github.com> Date: Sun, 15 Feb 2026 02:53:16 -0800 Subject: [PATCH 4/5] updates to MCDC dataset generator --- .github/workflows/ci.yml | 2 +- .github/workflows/workflow.yml | 1 + CHANGELOG.md | 46 ++++++++++++++ INSTALL.md | 24 ++++---- README.md | 6 +- docs/conf.py | 2 +- docs/getting_started.rst | 8 +-- pyepics/__init__.py | 4 +- pyepics/cli.py | 102 +++++++++++++++++++------------- pyepics/converters/__init__.py | 11 +++- pyepics/converters/hdf5.py | 83 ++++++++++++++++++++++++++ pyepics/converters/mcdc_hdf5.py | 70 ++++++++++++++++++++-- pyepics/pyeedl_compat.py | 2 +- pyproject.toml | 10 ++-- tests/test_pipeline.py | 83 ++++++++++++++++++++++++++ 15 files changed, 381 insertions(+), 73 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35040bd..67d898b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: # id-token: write # environment: # name: pypi - # url: https://pypi.org/p/pyepics-data + # url: https://pypi.org/p/epics # steps: # - uses: actions/download-artifact@v4 # with: diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 922b498..4ad7f09 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -8,6 +8,7 @@ jobs: build-n-publish: name: Build and publish to PyPI runs-on: ubuntu-latest + environment: pypi permissions: id-token: write contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..01db232 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to PyEPICS are documented in this file. + +## [1.0.0] — 2026-02-15 + +### Added + +- **Combined MCDC HDF5 output**: Each element now produces a single HDF5 + file (e.g. `Fe.h5`) containing `electron_reactions`, `photon_reactions`, + and `atomic_relaxation` groups together. +- `create_combined_mcdc_hdf5()` — new public API for writing combined files. +- `write_mcdc_combined()` — low-level writer accepting EEDL/EPDL/EADL datasets. +- Full **EPDL** (photon) support: reader, raw HDF5, MCDC HDF5 — cross sections, + form factors, photoelectric subshells, pair production. +- Full **EADL** (atomic relaxation) support: reader, raw HDF5, MCDC HDF5 — + subshell binding energies, radiative/non-radiative transitions, + fluorescence and Auger yields. +- `EPICSClient` — high-level API for querying element properties across + all three libraries (EEDL, EPDL, EADL). +- `ElementProperties` — container with binding energies, cross sections, + fluorescence data, and transition energies. +- CLI tool (`epics`) for download, raw, mcdc, and full pipeline execution. +- Sphinx documentation with API reference, data-sources guide, and pipeline docs. +- 164 unit tests covering readers, converters, mapping completeness, and pipeline. +- PDF regression-test report generator. + +### Changed + +- **PyPI package name** changed from `pyepics-data` to `epics`. + Import name remains `pyepics` (`import pyepics`). +- Electron-specific constants renamed for clarity: + `MF_MT` → `ELECTRON_MF_MT`, + `SECTIONS_ABBREVS` → `ELECTRON_SECTIONS_ABBREVS`, + `SUBSHELL_LABELS` → `ELECTRON_SUBSHELL_LABELS`. + Old names kept as backward-compatible aliases. +- MCDC CLI now produces one combined file per element in `data/mcdc/` + (previously separated into `data/mcdc/electron/`, `photon/`, `atomic/`). + +## [0.1.0] — 2026-12-18 + +### Added + +- Initial release with EEDL (electron) reader and HDF5 converter. +- Basic constant dictionaries and utility functions. +- PyEEDL backward-compatibility layer (`pyepics.pyeedl_compat`). diff --git a/INSTALL.md b/INSTALL.md index 73d756b..9360873 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,7 +7,7 @@ PyEPICS requires **Python 3.11 or later** (3.11, 3.12, 3.13). ## Quick Install from PyPI ```bash -pip install pyepics-data +pip install epics ``` This installs the core package with the minimum required dependencies @@ -19,23 +19,23 @@ PyEPICS defines optional dependency groups you can install as needed: | Extra | What it adds | Install command | |------------|-------------------------------------|------------------------------------------| -| `download` | `requests`, `beautifulsoup4` | `pip install "pyepics-data[download]"` | -| `pandas` | `pandas` | `pip install "pyepics-data[pandas]"` | -| `plot` | `matplotlib` | `pip install "pyepics-data[plot]"` | -| `all` | All optional dependencies | `pip install "pyepics-data[all]"` | -| `dev` | Testing + linting + docs tooling | `pip install "pyepics-data[dev]"` | +| `download` | `requests`, `beautifulsoup4` | `pip install "epics[download]"` | +| `pandas` | `pandas` | `pip install "epics[pandas]"` | +| `plot` | `matplotlib` | `pip install "epics[plot]"` | +| `all` | All optional dependencies | `pip install "epics[all]"` | +| `dev` | Testing + linting + docs tooling | `pip install "epics[dev]"` | ### Examples ```bash # Core only (reading ENDF files and converting to HDF5) -pip install pyepics-data +pip install epics # With plotting and pandas for interactive exploration -pip install "pyepics-data[plot,pandas]" +pip install "epics[plot,pandas]" # Everything (including download support) -pip install "pyepics-data[all]" +pip install "epics[all]" ``` ## Developer Install @@ -99,8 +99,8 @@ This produces: ``` dist/ -├── pyepics_data-0.1.0.tar.gz # sdist -└── pyepics_data-0.1.0-py3-none-any.whl # wheel +├── epics-0.1.0.tar.gz # sdist +└── epics-0.1.0-py3-none-any.whl # wheel ``` ## Verifying the Install @@ -125,5 +125,5 @@ print("EPICSClient loaded successfully") ```bash conda install numpy h5py - pip install pyepics-data + pip install epics ``` diff --git a/README.md b/README.md index cde2a16..f02318b 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: BSD-3](https://img.shields.io/badge/license-BSD--3--Clause-green.svg)](LICENSE) [![ReadTheDocs](https://readthedocs.org/projects/pyepics/badge/?version=latest)](https://pyepics.readthedocs.io/en/latest/) -> Python library for reading and converting EPICS (Electron Photon Interaction Cross Sections) nuclear data. +> Python library for reading and converting EPICS (Electron Photon Interaction Cross Sections) nuclear data by LLNL. PyEPICS parses EEDL, EPDL, and EADL files from the [LLNL EPICS 2025](https://nuclear.llnl.gov/EPICS/) database (in ENDF-6 format) and converts them into structured HDF5 files suitable for Monte Carlo transport codes such as [MC/DC](https://github.com/CEMeNT-PSAAP/MCDC). @@ -87,10 +87,10 @@ utils ← models ← readers ← converters (raw_hdf5 / mcdc_hdf5) ```bash # From PyPI (when published) -pip install pyepics-data +pip install epics # With all optional dependencies -pip install "pyepics-data[all]" +pip install "epics[all]" # From source (editable, for development) git clone https://github.com/melekderman/PyEPICS.git diff --git a/docs/conf.py b/docs/conf.py index 4303c8d..2eb0803 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -9,7 +9,7 @@ project = "PyEPICS" copyright = "2026, Melek Derman" author = "Melek Derman" -release = "0.1.0" +release = "1.0.0" extensions = [ "sphinx.ext.autodoc", diff --git a/docs/getting_started.rst b/docs/getting_started.rst index efa9b2e..d243f3d 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -4,21 +4,21 @@ Getting Started Installation ------------ -From PyPI (when published): +From PyPI: .. code-block:: bash - pip install pyepics-data + pip install epics With optional extras: .. code-block:: bash # Plotting and pandas support - pip install "pyepics-data[plot,pandas]" + pip install "epics[plot,pandas]" # All optional dependencies - pip install "pyepics-data[all]" + pip install "epics[all]" From source (development): diff --git a/pyepics/__init__.py b/pyepics/__init__.py index 49f581f..562edcb 100644 --- a/pyepics/__init__.py +++ b/pyepics/__init__.py @@ -48,12 +48,13 @@ from __future__ import annotations -__version__ = "0.1.0" +__version__ = "1.0.0" __author__ = "Melek Derman" from pyepics.client import ElementProperties, EPICSClient from pyepics.converters.hdf5 import ( convert_dataset_to_hdf5, + create_combined_mcdc_hdf5, create_mcdc_hdf5, create_raw_hdf5, ) @@ -83,6 +84,7 @@ "convert_dataset_to_hdf5", "create_raw_hdf5", "create_mcdc_hdf5", + "create_combined_mcdc_hdf5", # Exceptions "PyEPICSError", "ParseError", diff --git a/pyepics/cli.py b/pyepics/cli.py index d6115ce..2e3394d 100644 --- a/pyepics/cli.py +++ b/pyepics/cli.py @@ -43,9 +43,9 @@ photon/ ← raw HDF5 (photon) atomic/ ← raw HDF5 (atomic) mcdc/ - electron/ ← MCDC HDF5 (electron) - photon/ ← MCDC HDF5 (photon) - atomic/ ← MCDC HDF5 (atomic) + H.h5 ← combined MCDC (electron + photon + atomic) + He.h5 + ... """ from __future__ import annotations @@ -189,52 +189,74 @@ def cmd_raw(args): def cmd_mcdc(args): - """Create MCDC-format HDF5 files from ENDF sources.""" - from pyepics.converters.hdf5 import create_mcdc_hdf5 + """Create combined MCDC-format HDF5 files (one per element). + + Each output file contains ``electron_reactions``, + ``photon_reactions``, and ``atomic_relaxation`` groups — whichever + ENDF sources are available for that element. + """ + from pyepics.converters.hdf5 import create_combined_mcdc_hdf5 base = Path(args.data_dir) - libraries = args.libraries or list(LIBRARY_CONFIG.keys()) z_min, z_max = args.z_min, args.z_max - total_ok = 0 - total_fail = 0 + mcdc_dir = base / "data/mcdc" + mcdc_dir.mkdir(parents=True, exist_ok=True) - for lib_name in libraries: - cfg = LIBRARY_CONFIG[lib_name] - endf_dir = base / cfg["endf_dir"] - mcdc_dir = base / cfg["mcdc_dir"] - mcdc_dir.mkdir(parents=True, exist_ok=True) + # ENDF directories + eedl_dir = base / LIBRARY_CONFIG["electron"]["endf_dir"] + epdl_dir = base / LIBRARY_CONFIG["photon"]["endf_dir"] + eadl_dir = base / LIBRARY_CONFIG["atomic"]["endf_dir"] - print(f"\n{'=' * 60}") - print(f" Creating MCDC HDF5: {lib_name} ({cfg['dataset_type']})") - print(f" ENDF source: {endf_dir}") - print(f" Output: {mcdc_dir}") - print(f" Z range: {z_min}–{z_max}") - print(f"{'=' * 60}") + print(f"\n{'=' * 60}") + print(f" Creating combined MCDC HDF5 files") + print(f" Output: {mcdc_dir}") + print(f" Z range: {z_min}–{z_max}") + print(f"{'=' * 60}") - for Z in range(z_min, z_max + 1): - sym = _element_symbol(Z) - endf_file = _find_endf_file(endf_dir, cfg["endf_prefix"], Z) - if endf_file is None: - continue + total_ok = 0 + total_fail = 0 - out_path = mcdc_dir / f"{sym}.h5" - print(f" Z={Z:3d} ({sym:>2s}): {endf_file.name} -> {out_path.name}", end=" ... ", flush=True) + for Z in range(z_min, z_max + 1): + sym = _element_symbol(Z) + eedl_file = _find_endf_file(eedl_dir, "EEDL", Z) + epdl_file = _find_endf_file(epdl_dir, "EPDL", Z) + eadl_file = _find_endf_file(eadl_dir, "EADL", Z) + + if not any([eedl_file, epdl_file, eadl_file]): + continue + + libs = [] + if eedl_file: + libs.append("EEDL") + if epdl_file: + libs.append("EPDL") + if eadl_file: + libs.append("EADL") + + out_path = mcdc_dir / f"{sym}.h5" + print( + f" Z={Z:3d} ({sym:>2s}): {'+'.join(libs)} -> {out_path.name}", + end=" ... ", + flush=True, + ) - try: - create_mcdc_hdf5( - cfg["dataset_type"], - endf_file, - out_path, - overwrite=args.overwrite, - ) - print("OK") - total_ok += 1 - except Exception as exc: - print(f"FAIL: {exc}") - total_fail += 1 - if not args.continue_on_error: - return 1 + try: + create_combined_mcdc_hdf5( + Z, + out_path, + eedl_path=eedl_file, + epdl_path=epdl_file, + eadl_path=eadl_file, + overwrite=args.overwrite, + ) + print("OK") + total_ok += 1 + except Exception as exc: + print(f"FAIL: {exc}") + total_fail += 1 + if not args.continue_on_error: + return 1 print(f"\nMCDC HDF5: {total_ok} OK, {total_fail} failed") return 0 if total_fail == 0 else 1 diff --git a/pyepics/converters/__init__.py b/pyepics/converters/__init__.py index 7909843..e9433f6 100644 --- a/pyepics/converters/__init__.py +++ b/pyepics/converters/__init__.py @@ -16,14 +16,23 @@ Writes a "raw" HDF5 preserving original grids and breakpoints. * :func:`~pyepics.converters.hdf5.create_mcdc_hdf5` Writes an MCDC-optimised HDF5 with common energy grid and PDFs. +* :func:`~pyepics.converters.hdf5.create_combined_mcdc_hdf5` + Creates a single MCDC HDF5 per element with electron, photon, + and atomic data combined. """ from __future__ import annotations from pyepics.converters.hdf5 import ( convert_dataset_to_hdf5, + create_combined_mcdc_hdf5, create_mcdc_hdf5, create_raw_hdf5, ) -__all__ = ["convert_dataset_to_hdf5", "create_raw_hdf5", "create_mcdc_hdf5"] +__all__ = [ + "convert_dataset_to_hdf5", + "create_raw_hdf5", + "create_mcdc_hdf5", + "create_combined_mcdc_hdf5", +] diff --git a/pyepics/converters/hdf5.py b/pyepics/converters/hdf5.py index 587df40..8eaf6f8 100644 --- a/pyepics/converters/hdf5.py +++ b/pyepics/converters/hdf5.py @@ -709,3 +709,86 @@ def create_mcdc_hdf5( raise ConversionError(f"Failed to write MCDC HDF5 {out}: {exc}") from exc logger.info("Wrote MCDC %s HDF5: %s", dataset_type, out) + + +def create_combined_mcdc_hdf5( + Z: int, + output_path: Path | str, + *, + eedl_path: Path | str | None = None, + epdl_path: Path | str | None = None, + eadl_path: Path | str | None = None, + validate: bool = True, + overwrite: bool = False, +) -> None: + """Create a **single** MCDC HDF5 file containing electron, photon, and atomic data. + + Each element gets one file (e.g. ``Fe.h5``) with up to three + top-level groups: ``electron_reactions``, ``photon_reactions``, + and ``atomic_relaxation``. + + Parameters + ---------- + Z : int + Atomic number (used for logging only; actual Z comes from the + parsed data). + output_path : Path | str + Path for the combined output HDF5 file. + eedl_path : Path | str | None + Path to the EEDL ENDF source file (electron). + epdl_path : Path | str | None + Path to the EPDL ENDF source file (photon). + eadl_path : Path | str | None + Path to the EADL ENDF source file (atomic relaxation). + validate : bool, optional + Post-parse validation. Default ``True``. + overwrite : bool, optional + Overwrite existing file. Default ``False``. + + Examples + -------- + >>> create_combined_mcdc_hdf5( + ... 26, "data/mcdc/Fe.h5", + ... eedl_path="data/endf/eedl/EEDL.ZA026000.endf", + ... epdl_path="data/endf/epdl/EPDL.ZA026000.endf", + ... eadl_path="data/endf/eadl/EADL.ZA026000.endf", + ... ) + """ + from pyepics.converters.mcdc_hdf5 import write_mcdc_combined + + from pyepics.readers.eadl import EADLReader + from pyepics.readers.eedl import EEDLReader + from pyepics.readers.epdl import EPDLReader + + out = Path(output_path) + if out.exists() and not overwrite: + raise ConversionError(f"Output file {out} already exists and overwrite=False.") + + eedl_ds = EEDLReader().read(Path(eedl_path), validate=validate) if eedl_path else None + epdl_ds = EPDLReader().read(Path(epdl_path), validate=validate) if epdl_path else None + eadl_ds = EADLReader().read(Path(eadl_path), validate=validate) if eadl_path else None + + if not any([eedl_ds, epdl_ds, eadl_ds]): + raise ConversionError( + f"No ENDF source files found for Z={Z}. " + "At least one of eedl_path, epdl_path, eadl_path must be provided." + ) + + out.parent.mkdir(parents=True, exist_ok=True) + try: + mode = "w" if overwrite else "w-" + with h5py.File(str(out), mode) as h5f: + write_mcdc_combined(h5f, eedl=eedl_ds, epdl=epdl_ds, eadl=eadl_ds) + except Exception as exc: + if isinstance(exc, ConversionError): + raise + raise ConversionError(f"Failed to write combined MCDC HDF5 {out}: {exc}") from exc + + libs = [] + if eedl_ds: + libs.append("EEDL") + if epdl_ds: + libs.append("EPDL") + if eadl_ds: + libs.append("EADL") + logger.info("Wrote combined MCDC HDF5 (%s): %s", "+".join(libs), out) diff --git a/pyepics/converters/mcdc_hdf5.py b/pyepics/converters/mcdc_hdf5.py index f3bbfcc..194a903 100644 --- a/pyepics/converters/mcdc_hdf5.py +++ b/pyepics/converters/mcdc_hdf5.py @@ -83,10 +83,14 @@ def _create_xs_dataset( def _write_mcdc_metadata(h5f: h5py.File, dataset) -> None: - """Write top-level metadata expected by MC/DC.""" - h5f.create_dataset("atomic_weight_ratio", data=np.float64(dataset.atomic_weight_ratio)) - h5f.create_dataset("atomic_number", data=np.int64(dataset.Z)) - h5f.create_dataset("element_name", data=dataset.symbol) + """Write top-level metadata expected by MC/DC. + + Safe to call multiple times — skips datasets that already exist. + """ + if "atomic_number" not in h5f: + h5f.create_dataset("atomic_weight_ratio", data=np.float64(dataset.atomic_weight_ratio)) + h5f.create_dataset("atomic_number", data=np.int64(dataset.Z)) + h5f.create_dataset("element_name", data=dataset.symbol) # --------------------------------------------------------------------------- @@ -406,3 +410,61 @@ def write_mcdc_eadl(h5f: h5py.File, dataset: EADLDataset) -> None: ) logger.debug("Wrote MCDC EADL for Z=%d (%d subshells)", dataset.Z, dataset.n_subshells) + + +# --------------------------------------------------------------------------- +# Combined (all-in-one) MCDC writer +# --------------------------------------------------------------------------- + +def write_mcdc_combined( + h5f: h5py.File, + *, + eedl: EEDLDataset | None = None, + epdl: EPDLDataset | None = None, + eadl: EADLDataset | None = None, +) -> None: + """Write a combined MCDC HDF5 file with electron, photon, and atomic data. + + Produces a single file per element containing up to three top-level + groups (``electron_reactions``, ``photon_reactions``, + ``atomic_relaxation``) plus shared metadata. + + Parameters + ---------- + h5f : h5py.File + Open HDF5 file handle (write mode). + eedl : EEDLDataset or None + Parsed EEDL dataset (electron). + epdl : EPDLDataset or None + Parsed EPDL dataset (photon). + eadl : EADLDataset or None + Parsed EADL dataset (atomic relaxation). + + Raises + ------ + ValueError + If no dataset is provided. + """ + first = eedl or epdl or eadl + if first is None: + raise ValueError("At least one dataset (eedl, epdl, or eadl) must be provided.") + + _write_mcdc_metadata(h5f, first) + + if eedl is not None: + write_mcdc_eedl(h5f, eedl) + logger.debug(" [combined] electron_reactions written") + + if epdl is not None: + write_mcdc_epdl(h5f, epdl) + logger.debug(" [combined] photon_reactions written") + + if eadl is not None: + write_mcdc_eadl(h5f, eadl) + logger.debug(" [combined] atomic_relaxation written") + + logger.info( + "Wrote combined MCDC HDF5 for Z=%d (%s)", + first.Z, + first.symbol, + ) diff --git a/pyepics/pyeedl_compat.py b/pyepics/pyeedl_compat.py index 7bc19c1..313853f 100644 --- a/pyepics/pyeedl_compat.py +++ b/pyepics/pyeedl_compat.py @@ -36,7 +36,7 @@ ) # ── Version ───────────────────────────────────────────────────────────── -__version__ = "0.1.0" +__version__ = "1.0.0" # ── Data constants & mappings ─────────────────────────────────────────── # ── Converter ────────────────────────────────────────────────────────── diff --git a/pyproject.toml b/pyproject.toml index 838c2ac..f29936a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "pyepics-data" -version = "0.1.0" +name = "epics" +version = "1.0.0" description = "Python library for reading and converting EPICS (Electron Photon Interaction Cross Sections) nuclear data" readme = "README.md" license = {text = "BSD-3-Clause"} @@ -44,10 +44,10 @@ plot = [ "matplotlib>=3.7", ] all = [ - "pyepics-data[download,pandas,plot]", + "epics[download,pandas,plot]", ] dev = [ - "pyepics-data[all]", + "epics[all]", "pytest>=7.0", "ruff>=0.4", "sphinx>=7.0", @@ -62,7 +62,7 @@ Repository = "https://github.com/melekderman/PyEPICS" Issues = "https://github.com/melekderman/PyEPICS/issues" [project.scripts] -pyepics = "pyepics.cli:main" +epics = "pyepics.cli:main" [tool.setuptools.packages.find] include = ["pyepics*"] diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index a8e4687..79add7a 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -31,6 +31,7 @@ write_mcdc_eedl, write_mcdc_epdl, write_mcdc_eadl, + write_mcdc_combined, ) from pyepics.models.records import EADLDataset, EEDLDataset, EPDLDataset @@ -255,3 +256,85 @@ def test_fluorescence_yield(self, tmp_path, sample_eadl_dataset): k_grp = "atomic_relaxation/subshells/K" if f"{k_grp}/radiative" in h5f: assert f"{k_grp}/radiative/fluorescence_yield" in h5f + + +# ----------------------------------------------------------------------- +# Combined MCDC (one file with electron + photon + atomic) +# ----------------------------------------------------------------------- + +class TestMcdcCombined: + """Test combined MCDC HDF5 output (all libraries in one file).""" + + def test_creates_file( + self, tmp_path, sample_eedl_dataset, sample_epdl_dataset, sample_eadl_dataset, + ): + out = tmp_path / "combined.h5" + with h5py.File(str(out), "w") as h5f: + write_mcdc_combined( + h5f, + eedl=sample_eedl_dataset, + epdl=sample_epdl_dataset, + eadl=sample_eadl_dataset, + ) + assert out.exists() + + def test_has_all_three_groups( + self, tmp_path, sample_eedl_dataset, sample_epdl_dataset, sample_eadl_dataset, + ): + """A combined file must contain electron, photon, and atomic groups.""" + out = tmp_path / "combined.h5" + with h5py.File(str(out), "w") as h5f: + write_mcdc_combined( + h5f, + eedl=sample_eedl_dataset, + epdl=sample_epdl_dataset, + eadl=sample_eadl_dataset, + ) + with h5py.File(str(out), "r") as h5f: + assert "electron_reactions" in h5f + assert "photon_reactions" in h5f + assert "atomic_relaxation" in h5f + + def test_shared_metadata( + self, tmp_path, sample_eedl_dataset, sample_epdl_dataset, sample_eadl_dataset, + ): + """Metadata should be written exactly once (no duplicates).""" + out = tmp_path / "combined.h5" + with h5py.File(str(out), "w") as h5f: + write_mcdc_combined( + h5f, + eedl=sample_eedl_dataset, + epdl=sample_epdl_dataset, + eadl=sample_eadl_dataset, + ) + with h5py.File(str(out), "r") as h5f: + assert int(h5f["atomic_number"][()]) == sample_eedl_dataset.Z + assert "element_name" in h5f + + def test_partial_electron_only(self, tmp_path, sample_eedl_dataset): + """Should work with only electron data.""" + out = tmp_path / "combined_e.h5" + with h5py.File(str(out), "w") as h5f: + write_mcdc_combined(h5f, eedl=sample_eedl_dataset) + with h5py.File(str(out), "r") as h5f: + assert "electron_reactions" in h5f + assert "photon_reactions" not in h5f + assert "atomic_relaxation" not in h5f + + def test_partial_photon_atomic(self, tmp_path, sample_epdl_dataset, sample_eadl_dataset): + """Should work with photon + atomic only.""" + out = tmp_path / "combined_pa.h5" + with h5py.File(str(out), "w") as h5f: + write_mcdc_combined(h5f, epdl=sample_epdl_dataset, eadl=sample_eadl_dataset) + with h5py.File(str(out), "r") as h5f: + assert "electron_reactions" not in h5f + assert "photon_reactions" in h5f + assert "atomic_relaxation" in h5f + + def test_no_datasets_raises(self): + """Must raise ValueError when no data is provided.""" + import tempfile + with tempfile.NamedTemporaryFile(suffix=".h5") as tmp: + with h5py.File(tmp.name, "w") as h5f: + with pytest.raises(ValueError, match="At least one dataset"): + write_mcdc_combined(h5f) From 865b834f5c6032ca4dee0d44c078e7501713157a Mon Sep 17 00:00:00 2001 From: Melek Derman <48313913+melekderman@users.noreply.github.com> Date: Sun, 15 Feb 2026 02:57:29 -0800 Subject: [PATCH 5/5] fix the issue --- pyepics/cli.py | 2 +- pyepics/converters/hdf5.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyepics/cli.py b/pyepics/cli.py index 2e3394d..75abe0c 100644 --- a/pyepics/cli.py +++ b/pyepics/cli.py @@ -209,7 +209,7 @@ def cmd_mcdc(args): eadl_dir = base / LIBRARY_CONFIG["atomic"]["endf_dir"] print(f"\n{'=' * 60}") - print(f" Creating combined MCDC HDF5 files") + print(" Creating combined MCDC HDF5 files") print(f" Output: {mcdc_dir}") print(f" Z range: {z_min}–{z_max}") print(f"{'=' * 60}") diff --git a/pyepics/converters/hdf5.py b/pyepics/converters/hdf5.py index 8eaf6f8..7222c3b 100644 --- a/pyepics/converters/hdf5.py +++ b/pyepics/converters/hdf5.py @@ -755,7 +755,6 @@ def create_combined_mcdc_hdf5( ... ) """ from pyepics.converters.mcdc_hdf5 import write_mcdc_combined - from pyepics.readers.eadl import EADLReader from pyepics.readers.eedl import EEDLReader from pyepics.readers.epdl import EPDLReader