diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 04e10b71..21c8fa9e 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -2,7 +2,7 @@ name: pytest # Controls when the workflow will run on: [push, pull_request, workflow_dispatch] - # Triggers the workflow on push or pull request events but only for the master branch + # Triggers the workflow on push or pull request events for all branches # workflow_dispatch allows you to run this workflow manually from the Actions tab jobs: @@ -10,17 +10,15 @@ jobs: # The type of runner and python versions that the job will run on strategy: matrix: - os: [windows-latest, ubuntu-20.04, ubuntu-latest] - python-version: ["3.11", '3.12', '3.13'] + os: [windows-latest, ubuntu-latest] + python-version: ['3.11', '3.12', '3.13', '3.14'] full-test: - - ${{ contains(github.ref, 'master') || contains(github.ref, 'main') || startsWith(github.ref, 'refs/heads/release') }} + - ${{ github.ref == 'refs/heads/developing' || github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/release') }} exclude: - full-test: false - python-version: ["3.10"] + python-version: '3.11' - full-test: false - python-version: ["3.11"] - - full-test: false - os: "ubuntu-20.04" + python-version: '3.12' fail-fast: false runs-on: ${{ matrix.os }} @@ -108,8 +106,8 @@ jobs: # Upload coverage to Codecov. Do it only for one case of the matrix. - name: Upload coverage to Codecov - # do it only once for python 3.12 - if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + # do it only once for python 3.13 (runs on every branch) + if: matrix.python-version == '3.13' && matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: verbose: true diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 162e454d..bc412e42 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -27,4 +27,7 @@ sphinx: # Optionally declare the Python requirements required to build your docs python: install: - - requirements: docs/source/requirements.txt + - method: pip + path: . + extra_requirements: + - docs diff --git a/AGENTS.md b/AGENTS.md index f9e5d404..aced5992 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,14 +4,14 @@ JADE is a Python V&V (Verification & Validation) framework for nuclear data libr - **Documentation**: [ReadTheDocs](https://jade-a-nuclear-data-libraries-vv-tool.readthedocs.io/en/latest/) - **PyPI package**: `jadevv` (imports as `jade`) -- **Python**: ≥ 3.10 +- **Python**: ≥ 3.11 --- ## Setup ```bash -pip install -e .[dev,ui] # dev tools: pytest, ruff, sphinx; ui: ttkthemes +pip install -e .[dev,ui,docs] # dev: pytest, ruff; ui: ttkthemes; docs: sphinx ``` ## Running Tests @@ -59,8 +59,8 @@ Every transport-code-extensible concept uses this pattern: ```python # helper/constants.py -class CODE(str, Enum): - mcnp = "mcnp"; openmc = "openmc"; serpent = "serpent"; d1s = "d1s"; exp = "exp" +class CODE(Enum): + MCNP = "mcnp"; OPENMC = "openmc"; SERPENT = "serpent"; D1S = "d1s"; ACTINV = "actinv"; EXPERIMENT = "exp" # e.g. run/benchmark.py class SingleRun(ABC): ... @@ -70,7 +70,7 @@ class SingleRunFactory: def create(code: CODE, ...) -> SingleRun: ... ``` -The same pattern applies to `Library`/`LibraryFactory`, `Input`/`InputFactory`, and `AbstractSimOutput`. +The same pattern applies to `Library`/`LibraryFactory`. `Input` subclasses are selected by `CODE` inside `BenchmarkRun` (`run/benchmark.py`), and `AbstractSimOutput` subclasses inside `RawProcessor.__init__()` (`post/raw_processor.py`). ### Config construction: always via named classmethods @@ -96,7 +96,7 @@ Expensive state (e.g. `GlobalStatus.simulations`) uses `@property` with an inter ### `from __future__ import annotations` -Every module starts with this import. +New modules should start with this import (most existing modules already do). --- @@ -114,8 +114,8 @@ See [dev guide](docs/source/dev/add_transport_code.rst) for full details. The fo 1. `src/jade/helper/constants.py` — add tag to `CODE` enum 2. `src/jade/config/run_config.py` — subclass `Library`, register in `LibraryFactory` -3. `src/jade/run/benchmark.py` + `src/jade/run/input.py` — subclass `SingleRun` (implement `_build_command()`, `_get_lib_data_command()`) and `Input` (implement `set_nps()`, `translate()`, `_write()`); register in `SingleRunFactory` -4. `src/jade/post/sim_output.py` — subclass `AbstractSimOutput`; wire into `RawProcessor.__init__()` +3. `src/jade/run/benchmark.py` + `src/jade/run/input.py` — subclass `SingleRun` (implement `_build_command()`, `_get_lib_data_command()`) and `Input` (implement `set_nps()`, `translate()`, `_write()`); register in `SingleRunFactory` and in the input selection of `BenchmarkRun` +4. `src/jade/post/sim_output.py` — subclass `AbstractSimOutput`; wire into `RawProcessor.__init__()` in `src/jade/post/raw_processor.py` Add tests mirroring the new code's path under `tests/`. @@ -135,7 +135,7 @@ Input files are hosted externally (not in this repo): IAEA GitHub for open bench ## Branching & Versioning -- Permanent branches: `master` (stable), `developing` (integration) +- Permanent branches: `developing` (default, integration), `master` (stable) - Branch types: `Feature/*` and `Benchmark/*` off `developing`; `Release/*` for stabilization; `Hotfix/*` off `master` - **Semantic versioning**: Major = non-backwards-compatible change (new transport code, class restructure, folder layout). Minor = new features/benchmarks. Patch = bug fixes. - Every bug fix must include a regression test. @@ -145,7 +145,8 @@ Input files are hosted externally (not in this repo): IAEA GitHub for open bench ## CI Matrix -- **OS**: `ubuntu-latest`, `ubuntu-20.04`, `windows-latest` -- **Python**: 3.11, 3.12, 3.13 -- Full matrix only on `master`/`release*`; feature branches run latest Python on Ubuntu only. +- **OS**: `ubuntu-latest`, `windows-latest` +- **Python**: 3.11, 3.12, 3.13, 3.14 +- Full matrix only on pushes to `developing`/`master`/`release*`; other branches and PRs run only the two latest Python versions (both OSes). +- Coverage is uploaded to Codecov from the Ubuntu / Python 3.13 job. - OpenMC is installed on Linux only; Windows CI skips OpenMC tests. diff --git a/docs/source/requirements.txt b/docs/source/requirements.txt deleted file mode 100644 index e1710df6..00000000 --- a/docs/source/requirements.txt +++ /dev/null @@ -1,20 +0,0 @@ -# JADE reqs -pandas -numjuggler -tqdm -xlsxwriter -openpyxl -matplotlib -scipy -python-docx -aspose-words -requests -f4enix >= 0.12.0 -seaborn -# DOC reqs -sphinx -esbonio -myst-parser -pydata-sphinx-theme -pyyaml -pygments \ No newline at end of file diff --git a/docs/source/usage/installation.rst b/docs/source/usage/installation.rst index d0dc56dc..ffd206a3 100644 --- a/docs/source/usage/installation.rst +++ b/docs/source/usage/installation.rst @@ -23,7 +23,7 @@ Or if you are using Anaconda: | ``conda activate jade`` .. warning:: - JADE officially supports only Python 3.10, 3.11 and 3.12. + JADE officially supports only Python 3.11, 3.12, 3.13 and 3.14. It may work with other versions, but they are untested. Install the JADE package @@ -74,10 +74,21 @@ You can either dowload the zip file or clone it using git with: Moving into the downloaded folder, the user can install the ``jade`` python package through a local pip install. -It is recommeded to use the '-e' option when installing (editable mode) -and you should also install the additional 'dev' dependencies. +It is recommended to use the '-e' option when installing (editable mode), +together with the optional dependency groups needed for development: - | ``pip install -e .[dev]`` + | ``pip install -e .[dev,ui,docs]`` + +The available optional dependency groups are: + +- ``dev``: testing and formatting tools (``pytest``, ``pytest-cov``, ``pytest-xvfb``, ``ruff``). +- ``ui``: GUI dependencies (``ttkthemes``). Skip it on systems without Tkinter. +- ``docs``: tools needed to build this documentation locally (``sphinx`` and its extensions). + +All dependencies are declared in ``pyproject.toml``, which is the single source of truth +(the documentation build on Read the Docs also installs from it). Most dependencies are capped +at their current major version (minor version for ``0.x`` packages); when bumping a cap, +make sure the test suite still passes. If your dev system allows it, install also openmc in the same python environment. diff --git a/pyproject.toml b/pyproject.toml index 98f5f2e4..bb64c353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,38 +11,39 @@ authors = [ ] description = "JADE, a V&V tool for nuclear data libraries and transport codes" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: European Union Public Licence 1.2 (EUPL 1.2)", "Operating System :: OS Independent", ] dependencies = [ - "xlsxwriter", - "matplotlib", - "aspose-words", - "requests", - "f4enix >= 1.0.0", - "pyyaml", - "seaborn", - "python-gitlab" + "xlsxwriter < 4", + "matplotlib < 4", + "requests < 3", + "f4enix >= 1.2.1, < 2", + "pyyaml < 7", + "seaborn < 0.14", + "python-gitlab < 9" ] [project.optional-dependencies] dev = [ - "pytest", - "pytest-cov", - "pytest-xvfb", # needed for headless display + "pytest < 10", + "pytest-cov < 8", + "pytest-xvfb < 4", # needed for headless display "ruff", # best formatter that should be used by everybody - "sphinx", - "myst-parser", - "sphinx-book-theme", - "sphinx-copybutton", - "sphinx-togglebutton", - "sphinx-design" +] +docs = [ + "sphinx < 10", + "myst-parser < 6", + "sphinx-book-theme < 2", + "sphinx-copybutton < 0.6", + "sphinx-togglebutton < 0.5", + "sphinx-design < 0.8" ] ui = [ - "ttkthemes" + "ttkthemes < 4" ] [project.urls] diff --git a/src/jade/app/fetch.py b/src/jade/app/fetch.py index 44b5a6e2..24284f71 100644 --- a/src/jade/app/fetch.py +++ b/src/jade/app/fetch.py @@ -16,6 +16,7 @@ IAEA_URL = r"https://github.com/IAEA-NDS/open-benchmarks/archive/main.zip" RAW_DATA_GITHUB_URL = r"https://github.com/JADE-V-V/JADE-RAW-RESULTS/archive/main.zip" +GITLAB_TIMEOUT = 30 # seconds def _fetch_from_git( @@ -90,13 +91,22 @@ def fetch_from_gitlab( extracted_folder: Pathlike | bool path to the extracted folder. False if the download was not successful. """ - gl = gitlab.Gitlab(url=url, private_token=authorization_token, ssl_verify=False) + gl = gitlab.Gitlab( + url=url, + private_token=authorization_token, + ssl_verify=False, + timeout=GITLAB_TIMEOUT, + ) try: gl.auth() except gitlab.exceptions.GitlabAuthenticationError as e: logger.error("Gitlab authentication failed") logger.error(e) return False + except requests.exceptions.RequestException as e: + logger.error(f"Could not connect to {url}") + logger.error(e) + return False # select the correct project found = False @@ -125,6 +135,7 @@ def _install_data(fetch_folder: str | os.PathLike, install_folder: str | os.Path os.path.join(install_folder, item), ) + def _install_standard_folder_structure( extracted_folder: str | os.PathLike, inputs_root: PathLike, diff --git a/src/jade/post/excel_routines.py b/src/jade/post/excel_routines.py index 378870fd..b4fa915c 100644 --- a/src/jade/post/excel_routines.py +++ b/src/jade/post/excel_routines.py @@ -180,7 +180,7 @@ def _rename_columns(df: pd.DataFrame, names: dict[str, str]) -> None: col_names = {k: v for k, v in names.items() if k in df.columns.names} df.columns = df.columns.rename(col_names) else: - df.rename(columns=names, inplace=True) + df.columns = df.rename(columns=names).columns @staticmethod def _select_common_index_data( @@ -223,7 +223,7 @@ def _get_sheet(self) -> list[pd.DataFrame]: ) # this is needed to avoid NaN in the multiindex which would cause incorrect dump value_df.columns = pd.MultiIndex.from_frame( - value_df.columns.to_frame().fillna("") + value_df.columns.to_frame().astype(object).fillna("") ) if self.cfg.add_error: @@ -233,7 +233,7 @@ def _get_sheet(self) -> list[pd.DataFrame]: # this is needed to avoid NaN in the multiindex which would cause incorrect # dump ref_err.columns = pd.MultiIndex.from_frame( - ref_err.columns.to_frame().fillna("") + ref_err.columns.to_frame().astype(object).fillna("") ) target_err = self.target_df.pivot( @@ -242,7 +242,7 @@ def _get_sheet(self) -> list[pd.DataFrame]: # this is needed to avoid NaN in the multiindex which would cause incorrect # dump target_err.columns = pd.MultiIndex.from_frame( - target_err.columns.to_frame().fillna("") + target_err.columns.to_frame().astype(object).fillna("") ) return [value_df, ref_err, target_err] diff --git a/src/jade/post/manipulate_tally.py b/src/jade/post/manipulate_tally.py index 3a3a5f9e..8b405525 100644 --- a/src/jade/post/manipulate_tally.py +++ b/src/jade/post/manipulate_tally.py @@ -260,7 +260,7 @@ def groupby(tally: pd.DataFrame, by: str, action: str) -> pd.DataFrame: # a series has been created but we want a df df = df.to_frame().T else: - df.reset_index(inplace=True) + df = df.reset_index() return df diff --git a/src/jade/post/plotter.py b/src/jade/post/plotter.py index 2358c8b9..25ffd726 100644 --- a/src/jade/post/plotter.py +++ b/src/jade/post/plotter.py @@ -753,9 +753,9 @@ def _get_figure(self) -> tuple[Figure, Axes]: if isinstance(axes, Axes): axes = [axes] + data = [(codelib, df.set_index("User")) for codelib, df in self.data] indices = [] - for idx, (codelib, df) in enumerate(self.data): - df.set_index("User", inplace=True) + for _, df in data: indices.extend(df.index.to_list()) # build a common index of isotopes so that the colors are the same for all @@ -766,7 +766,7 @@ def _get_figure(self) -> tuple[Figure, Axes]: for idx, isotope in enumerate(isotopes): if int(isotope) == 0: continue - for i, (codelib, df) in enumerate(self.data): + for i, (_, df) in enumerate(data): if i == 0: _, label = LM.get_zaidname(str(abs(isotope))) else: @@ -788,7 +788,7 @@ def _get_figure(self) -> tuple[Figure, Axes]: linewidth=0.5, ) - for i, ax in enumerate(axes): + for ax, (codelib, _) in zip(axes, data): # --- Plot details --- # ax details ax.set_title(codelib) @@ -1177,6 +1177,8 @@ def _shorten_x_name(ax: Axes, shorten_x_name: int) -> None: for label in ax.get_xticklabels(): split = label.get_text().split("_") new_labels.append(" ".join(split[-shorten_x_name:])) + # fix the current ticks so that the new labels stay attached to them + ax.set_xticks(ax.get_xticks()) ax.set_xticklabels(new_labels) diff --git a/src/jade/post/sim_output.py b/src/jade/post/sim_output.py index 1afa64e0..e5bb0d70 100644 --- a/src/jade/post/sim_output.py +++ b/src/jade/post/sim_output.py @@ -130,7 +130,7 @@ def __init__( tallydata = mctal.tallydata total_bin = mctal.totalbin for dictionary in [tallydata, total_bin]: - for _, df in dictionary.items(): + for key, df in dictionary.items(): if df is not None: if ( "Cells" in df.columns @@ -156,9 +156,8 @@ def __init__( if len(usr_bins) <= 2 and "total" in usr_bins: # then the column does not add any additional info, to drop del df["User"] - # and drop the duplicates ignoring the warning - with pd.option_context("mode.chained_assignment", None): - df.drop_duplicates(inplace=True) + # and drop the duplicates + dictionary[key] = df.drop_duplicates() except KeyError: pass # no user column @@ -195,17 +194,16 @@ def __init__( else: self._tally_comments.append("") - for df in tallydata.values(): + for key, df in tallydata.items(): # drop a row if it contains total in whatever column if "total" in df.values: - df.drop( + tallydata[key] = df.drop( df[ df.apply( lambda row: row.astype(str).str.contains("total").any(), axis=1, ) - ].index, - inplace=True, + ].index ) self._tallydata = tallydata diff --git a/src/jade/run/input.py b/src/jade/run/input.py index 0aefafe5..2b139f0a 100644 --- a/src/jade/run/input.py +++ b/src/jade/run/input.py @@ -192,7 +192,9 @@ def _get_material(self, zaid: str | Material) -> Material: if isinstance(zaid, Material): material = deepcopy(zaid) # just to be sure not to mess with something truename = material.name - material.header = f"{material.header}C\nC True name:{truename}" + # f4enix strips the trailing newline from headers + header = f"{material.header}\n" if material.header else "" + material.header = f"{header}C\nC True name:{truename}" material.name = "M1" # override the input name self._name = f"{self.name}_{truename}" @@ -283,7 +285,9 @@ def _get_material(self, zaid: str | Material) -> Material: if isinstance(zaid, Material): material = deepcopy(zaid) # just to be sure not to mess with something truename = material.name - material.header = f"{material.header}C\nC True name:{truename}" + # f4enix strips the trailing newline from headers + header = f"{material.header}\n" if material.header else "" + material.header = f"{header}C\nC True name:{truename}" material.name = "M1" # override the input name self._name = f"{self.name}_{truename}" @@ -445,12 +449,13 @@ def __init__( if isinstance(zaid, Material): if self.lib.suffix is None: raise ConfigError("suffix must be provided for D1S libraries") - self.inp.reac_file = ReactionFile( - self.inp.get_potential_paths(self.lm, lib.suffix) - ) - daughters = [] - for reac in self.inp.reac_file.reactions: - daughters.append(reac.daughter) + # keep only reactions whose daughters are in the irradiation file, + # and only the irradiations of daughters produced by those reactions + self.inp.get_reaction_file(self.lm, lib.suffix, set_as_attribute=True) + daughters = [ + reac.daughter.write_to_int_string() + for reac in self.inp.reac_file.reactions + ] self.inp.irrad_file.select_daughters_irradiation_file(daughters) self._name = f"{self.name}_All" else: @@ -461,7 +466,7 @@ def __init__( reaction = Reaction( Nuclide.from_int_string(f"{zaid}.{lib.suffix}"), MT, - Nuclide.from_int_string(f"{daughter}.{lib.suffix}"), + Nuclide.from_int_string(daughter), ) reacfile = ReactionFile([reaction]) self.inp.reac_file = reacfile diff --git a/tests/post/test_plotter.py b/tests/post/test_plotter.py index bde08258..446ea5b1 100644 --- a/tests/post/test_plotter.py +++ b/tests/post/test_plotter.py @@ -2,14 +2,23 @@ import random import string +import warnings +import matplotlib.pyplot as plt import numpy as np import pandas as pd import pytest from jade.config.atlas_config import PlotConfig from jade.helper.errors import PlotIndexMismatchError -from jade.post.plotter import BarPlot, BinnedPlot, CEPlot, WavesPlot +from jade.post.plotter import ( + BarPlot, + BinnedPlot, + CEPlot, + DoseContributionPlot, + WavesPlot, + _shorten_x_name, +) class TestBinnedPlot: @@ -265,3 +274,48 @@ def test_plot(self, tmpdir): plot = BarPlot(cfg, data) output = plot.plot() output[0][0].savefig(tmpdir.join("test.png"), bbox_inches="tight") + + +class TestDoseContributionPlot: + def test_plot(self, tmpdir): + cfg = PlotConfig( + name="test", + results=["a"], + plot_type=None, # dummy value + title="test", + x_label="Time", + y_labels=["dummy"], + x="Time", + y="Value", + ) + data = [] + for lib in ["lib0", "lib1"]: + df = pd.DataFrame( + { + "User": [11024, 11024, 27060, 27060], + "Time": [1, 2, 1, 2], + "Value": np.random.rand(4), + "Error": np.random.rand(4) * 0.1, + } + ) + data.append((lib, df)) + + plot = DoseContributionPlot(cfg, data) + fig, axes = plot.plot()[0] + fig.savefig(tmpdir.join("test.png")) + + # each subplot is titled after its own code-lib + assert [ax.get_title() for ax in axes] == ["lib0", "lib1"] + # the input data is not modified + for _, df in data: + assert "User" in df.columns + + +def test_shorten_x_name(): + fig, ax = plt.subplots() + ax.plot(["a_b_c", "d_e_f"], [1, 2]) + with warnings.catch_warnings(): + warnings.simplefilter("error") + _shorten_x_name(ax, 2) + assert [label.get_text() for label in ax.get_xticklabels()] == ["b c", "e f"] + plt.close(fig) diff --git a/tests/run/test_input.py b/tests/run/test_input.py index 4366ddfa..19a1708c 100644 --- a/tests/run/test_input.py +++ b/tests/run/test_input.py @@ -6,8 +6,9 @@ import f4enix.input.MCNPinput as ipt import pytest +from f4enix.input.d1suned import IrradiationFile, ReactionFile from f4enix.input.libmanager import LibManager -from f4enix.input.materials import Zaid +from f4enix.input.materials import Material, Zaid import jade.resources.default_cfg.benchmarks as bench import tests.dummy_structure.benchmark_templates as res @@ -117,6 +118,24 @@ def test_input_generation(self, libMCNP, tmpdir): assert nps_line[:6] == "NPS 10" assert read_inp.mat_section["M1"].zaids[0].library == "31c" + def test_material_header(self, libMCNP, tmpdir): + # header without trailing newline (as stored by f4enix) must not be + # glued to the extra comment lines, producing an invalid "CC" line + template_folder = TEMPLATE_ROOT.joinpath("Sphere/Sphere/mcnp") + zaid = Zaid.from_string("1001.31c 1.0") + material = Material("M101", [zaid], header="C my material\nC") + inp = InputMCNPSphere(template_folder, libMCNP, material, "1.0") + inp.write(tmpdir) + + read_inp = ipt.Input.from_input(Path(tmpdir).joinpath("Sphere_M101.i")) + header = read_inp.mat_section["M1"].header + assert header.splitlines() == [ + "C my material", + "C", + "C", + "C True name:M101", + ] + @pytest.mark.skipif(not OMC_AVAIL, reason="OpenMC not available") class TestInputOpenMCSphere: @@ -148,3 +167,25 @@ def test_input_generation(self, tmpdir, libD1S): assert os.path.exists(Path(tmpdir).joinpath("SphereSDDR_11023_Na-23_16.i")) assert os.path.getsize(Path(tmpdir).joinpath("irrad")) > 0 assert os.path.getsize(Path(tmpdir).joinpath("react")) > 0 + + def test_input_generation_material(self, tmpdir, libD1S): + # the irradiation file must keep exactly the daughters produced by the + # reactions (it used to be silently emptied) + template_folder = TEMPLATE_ROOT.joinpath("SphereSDDR/SphereSDDR/d1s") + irrad_file = CONFIG_BENCH.joinpath("SphereSDDR/irrad_99c.txt") + materials = ipt.Input.from_input( + TEMPLATE_ROOT.joinpath("TypicalMaterials") + ).mat_section + inp = InputD1SSphere( + template_folder, libD1S, materials["M101"], "1.0", irrad_file=irrad_file + ) + inp.set_nps(10) + inp.translate() + inp.write(tmpdir) + + irrad = IrradiationFile.from_text(Path(tmpdir).joinpath("irrad")) + react = ReactionFile.from_text(Path(tmpdir).joinpath("react")) + irrad_daughters = {d.write_to_int_string() for d in irrad.get_daughters()} + react_daughters = {r.daughter.write_to_int_string() for r in react.reactions} + assert len(irrad_daughters) > 0 + assert irrad_daughters == react_daughters