diff --git a/.github/actions/setup_env/action.yml b/.github/actions/setup_env/action.yml new file mode 100644 index 00000000..c6d579ce --- /dev/null +++ b/.github/actions/setup_env/action.yml @@ -0,0 +1,31 @@ +name: "Setup environment" +description: "Sets up environment for stardis and caches it" + +inputs: + os-label: + description: "os label for lock file, default linux" + required: true + default: "linux-64" + +runs: + using: "composite" + steps: + - name: Download Lock File + run: wget -q https://raw.githubusercontent.com/tardis-sn/stardis/main/conda-${{ inputs.os-label }}.lock + shell: bash + + - name: Generate Cache Key + run: | + file_hash=$(cat conda-${{ inputs.os-label }}.lock | shasum -a 256 | cut -d' ' -f1) + echo "file_hash=$file_hash" >> "${GITHUB_OUTPUT}" + id: cache-environment-key + shell: bash + + - uses: mamba-org/setup-micromamba@v1 + with: + environment-file: conda-${{ inputs.os-label }}.lock + cache-environment-key: ${{ steps.cache-environment-key.outputs.file_hash }} + cache-downloads-key: ${{ steps.cache-environment-key.outputs.file_hash }} + environment-name: stardis + cache-environment: true + cache-downloads: true diff --git a/.github/actions/setup_lfs/action.yml b/.github/actions/setup_lfs/action.yml new file mode 100644 index 00000000..3855931b --- /dev/null +++ b/.github/actions/setup_lfs/action.yml @@ -0,0 +1,52 @@ +name: "Setup LFS" +description: "Pull LFS repositories and caches them" + + +inputs: + regression-data-repo: + description: "stardis regression data repository" + required: false + default: "tardis-sn/stardis-regression-data" + +runs: + using: "composite" + steps: + - name: Clone tardis-sn/stardis-regression-data + uses: actions/checkout@v4 + with: + repository: ${{ inputs.regression-data-repo }} + path: stardis-regression-data + + - name: Create LFS file list + run: git lfs ls-files -l | cut -d' ' -f1 | sort > .lfs-assets-id + working-directory: stardis-regression-data + shell: bash + + - name: Restore LFS cache + uses: actions/cache/restore@v4 + id: lfs-cache-regression-data + with: + path: stardis-regression-data/.git/lfs + key: ${{ runner.os }}-lfs-${{ hashFiles('stardis-regression-data/.lfs-assets-id') }}-v1 + + - name: Git LFS Pull + run: git lfs pull + working-directory: stardis-regression-data + if: steps.lfs-cache-regression-data.outputs.cache-hit != 'true' + shell: bash + + - name: Git LFS Checkout + run: git lfs checkout + working-directory: stardis-regression-data + if: steps.lfs-cache-regression-data.outputs.cache-hit == 'true' + shell: bash + + - name: Save LFS cache if not found + # uses fake ternary + # for reference: https://github.com/orgs/community/discussions/26738#discussioncomment-3253176 + if: ${{ steps.lfs-cache-regression-data.outputs.cache-hit != 'true' && !contains(github.ref, 'merge') && always() || false }} + uses: actions/cache/save@v4 + id: lfs-cache-regression-data-save + with: + path: stardis-regression-data/.git/lfs + key: ${{ runner.os }}-lfs-${{ hashFiles('stardis-regression-data/.lfs-assets-id') }}-v1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f782247b..ca114ef6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,12 +2,16 @@ name: tests on: push: + branches: + - main pull_request: + branches: + - main workflow_dispatch: env: CACHE_NUMBER: 0 # increase to reset cache manually - PYTEST_FLAGS: --cov=stardis --cov-report=xml --cov-report=html + PYTEST_FLAGS: --cov=stardis --cov-report=xml --cov-report=html --stardis-regression-data=${{ github.workspace }}/stardis-regression-data CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: @@ -16,19 +20,16 @@ defaults: jobs: build: - # if: github.repository_owner == 'tardis-sn' + if: github.repository_owner == 'tardis-sn' strategy: matrix: include: - - os: ubuntu-latest label: linux-64 prefix: /usr/share/miniconda3/envs/stardis - # - os: ubuntu-latest # label: linux-64-cuda # prefix: /usr/share/miniconda3/envs/stardis - - os: macos-latest label: osx-64 prefix: /Users/runner/miniconda3/envs/stardis @@ -37,39 +38,23 @@ jobs: runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 - - - name: Setup Mambaforge - uses: conda-incubator/setup-miniconda@v2 - with: - miniforge-variant: Mambaforge - miniforge-version: latest - activate-environment: stardis - use-mamba: true + - uses: actions/checkout@v4 - - - name: Cache lockfile - uses: actions/cache@v2 + - name: Setup LFS + uses: ./.github/actions/setup_lfs + + - name: Setup STARDIS + uses: ./.github/actions/setup_env with: - path: ${{ matrix.prefix }} - key: conda-${{ matrix.label }}-${{ hashFiles(format('conda-{0}.lock', matrix.label)) }}-${{ env.CACHE_NUMBER }} - id: cache-conda - - - name: Update Conda Environment - id: update-env - run: | - mamba update -n stardis --file conda-${{ matrix.label }}.lock - if: steps.cache-conda.outputs.cache-hit != 'true' + os-label: ${{ matrix.label }} - name: Install TARDIS id: install-tardis - # shell: bash -l {0} run: | pip install git+https://github.com/tardis-sn/tardis.git@release-2024.08.25 - name: Install STARDIS id: install-stardis - # shell: bash -l {0} run: | pip install -e .[test] diff --git a/stardis/conftest.py b/stardis/conftest.py index 4aa591e1..622dfba8 100644 --- a/stardis/conftest.py +++ b/stardis/conftest.py @@ -1,10 +1,11 @@ +import os import pytest from pathlib import Path import numpy as np from astropy import units as u from tardis.io.atom_data.base import AtomData -from tardis.io.atom_data.util import download_atom_data +from tardis.io.atom_data import download_atom_data from tardis.io.configuration.config_validator import validate_yaml from tardis.io.configuration.config_reader import Configuration from stardis.io.model.marcs import read_marcs_model @@ -15,6 +16,8 @@ from stardis.radiation_field.source_functions.blackbody import blackbody_flux_at_nu from stardis import STARDISOutput from stardis.io.base import SCHEMA_PATH +from stardis.util import regression_data + EXAMPLE_CONF_PATH = Path(__file__).parent / "tests" / "stardis_test_config.yml" EXAMPLE_CONF_PATH_BROADENING = ( @@ -24,6 +27,27 @@ Path(__file__).parent / "tests" / "stardis_test_config_parallel.yml" ) +# ensuring that regression_data is not removed by ruff +assert regression_data is not None + +pytest_plugins = [ + "stardis.util.regression_data", +] + + +def pytest_addoption(parser): + parser.addoption( + "--stardis-regression-data", + default=None, + help="Path to the stardis regression data directory", + ) + parser.addoption( + "--generate-reference", + action="store_true", + default=False, + help="generate reference data instead of testing", + ) + @pytest.fixture(scope="session") def example_tracing_nus(): @@ -281,3 +305,12 @@ def example_stardis_output_parallel( example_stellar_plasma, example_stellar_radiation_field_parallel, ) + + +@pytest.fixture(scope="session") +def stardis_regression_path(request): + stardis_regression_path = request.config.getoption("--stardis-regression-data") + if stardis_regression_path is None: + pytest.skip("--stardis-regression-data was not specified") + else: + return Path(os.path.expandvars(os.path.expanduser(stardis_regression_path))) diff --git a/stardis/model/base.py b/stardis/model/base.py index f6b4e285..92a1b088 100644 --- a/stardis/model/base.py +++ b/stardis/model/base.py @@ -1,4 +1,7 @@ -class StellarModel: +from tardis.io.util import HDFWriterMixin + + +class StellarModel(HDFWriterMixin): """ Class containing information about the stellar model. @@ -20,6 +23,8 @@ class StellarModel: Class attribute to be easily accessible for initializing arrays that need to match the shape of the model. """ + hdf_properties = ["temperatures", "geometry", "composition"] + def __init__(self, temperatures, geometry, composition): self.temperatures = temperatures self.geometry = geometry diff --git a/stardis/model/geometry/radial1d.py b/stardis/model/geometry/radial1d.py index 20155a92..7c752f42 100644 --- a/stardis/model/geometry/radial1d.py +++ b/stardis/model/geometry/radial1d.py @@ -1,5 +1,4 @@ class Radial1DGeometry: - """ Holds information about model geometry (distribution of depth points) for radial 1D models. diff --git a/stardis/radiation_field/base.py b/stardis/radiation_field/base.py index 0f5e1680..82a7cb12 100644 --- a/stardis/radiation_field/base.py +++ b/stardis/radiation_field/base.py @@ -4,9 +4,10 @@ from stardis.radiation_field.opacities.opacities_solvers import calc_alphas from stardis.radiation_field.radiation_field_solvers import raytrace from stardis.radiation_field.source_functions.blackbody import blackbody_flux_at_nu +from tardis.io.util import HDFWriterMixin -class RadiationField: +class RadiationField(HDFWriterMixin): """ Class containing information about the radiation field. ###TODO Radiation field temperature should be a separate attribute, for the case of differing gas and radiation. @@ -30,6 +31,8 @@ class RadiationField: Radiation field fluxes at each frequency at each depth point. Initialized as zeros and calculated by a solver. """ + hdf_properties = ["frequencies", "opacities", "F_nu"] + def __init__(self, frequencies, source_function, stellar_model): self.frequencies = frequencies self.source_function = source_function diff --git a/stardis/tests/stardis_test_config.yml b/stardis/tests/stardis_test_config.yml index 7b2c4d5f..71e44ccc 100644 --- a/stardis/tests/stardis_test_config.yml +++ b/stardis/tests/stardis_test_config.yml @@ -14,3 +14,7 @@ opacity: disable: False broadening: [] no_of_thetas: 1 +result_options: + return_model: true + return_plasma: true + return_radiation_field: true \ No newline at end of file diff --git a/stardis/tests/test_stardis_regression.py b/stardis/tests/test_stardis_regression.py new file mode 100644 index 00000000..eadb8108 --- /dev/null +++ b/stardis/tests/test_stardis_regression.py @@ -0,0 +1,95 @@ +import numpy as np +import pandas as pd + +plasma_properties = [ + "alpha_line", + "beta_rad", + "electron_densities", + "g", + "g_electron", + "general_level_boltzmann_factor", + "ion_number_density", + "ionization_data", + "level_boltzmann_factor", + "level_number_density", + "levels", + "lines", + "lines_lower_level_index", + "lines_upper_level_index", + "metastability", + "nlte_excitation_species", + "nlte_ionization_species", + "nu", + "number_density", + "partition_function", + "phi", + "selected_atoms", + "stimulated_emission_factor", + "t_electrons", + "t_rad", + "wavelength_cm", +] + +plasma_properties_complex = [] + + +def test_stardis_stellar_model(example_stardis_output, regression_data): + actual = example_stardis_output.stellar_model + expected = regression_data.sync_hdf_store(actual) + + np.testing.assert_allclose( + actual.temperatures.value, expected["/stellar_model/temperatures"] + ) + + +def test_stardis_stellar_model_geometry(example_stardis_output, regression_data): + actual = example_stardis_output.stellar_model.geometry.r.value + expected = regression_data.sync_ndarray(actual) + np.testing.assert_allclose(actual, expected) + + +def test_stardis_plasma(example_stardis_output, regression_data): + expected = regression_data.sync_hdf_store(example_stardis_output.stellar_plasma) + actual_plasma = example_stardis_output.stellar_plasma + for item in plasma_properties: + actual_item = getattr(actual_plasma, item) + expected_item = expected[f"plasma/{item}"] + if isinstance(actual_item, list): + actual_item = np.ndarray(actual_item) + + if any( + isinstance(actual_item, object_type) + for object_type in [pd.MultiIndex, pd.Index] + ): + expected_item = expected[f"plasma/{item}"].values.flatten() + np.testing.assert_array_equal(actual_item.values, expected_item) + + elif isinstance(actual_item, np.ndarray): + np.testing.assert_allclose( + actual=actual_item, + desired=expected_item, + err_msg=f"Error encountered when comparing regression data for property- {item}", + ) + elif isinstance(actual_item, pd.DataFrame): + pd.testing.assert_frame_equal(actual_item, expected_item) + elif isinstance(actual_item, pd.Series): + pd.testing.assert_series_equal(actual_item, expected_item) + else: + plasma_properties_complex.append(item) + if plasma_properties_complex: + raise AssertionError( + f"These properties were not tested- {plasma_properties_complex}" + ) + + +def test_stardis_radiation_field(example_stardis_output, regression_data): + expected = regression_data.sync_hdf_store( + example_stardis_output.stellar_radiation_field + ) + actual = example_stardis_output.stellar_radiation_field + + np.testing.assert_allclose( + expected["/radiation_field/frequencies"].values, actual.frequencies.value + ) + + np.testing.assert_allclose(expected["/radiation_field/F_nu"].values, actual.F_nu) diff --git a/stardis/util/__init__.py b/stardis/util/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/stardis/util/regression_data.py b/stardis/util/regression_data.py new file mode 100644 index 00000000..65bfb689 --- /dev/null +++ b/stardis/util/regression_data.py @@ -0,0 +1,165 @@ +import os +import re +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from tardis.io.util import HDFWriterMixin + + +class RegressionData: + def __init__(self, request) -> None: + self.request = request + if request.config.getoption("--stardis-regression-data") is None: + pytest.skip("--stardis-regression-data was not specified") + regression_data_path = Path( + request.config.getoption("--stardis-regression-data") + ) + self.regression_data_path = Path( + os.path.expandvars(regression_data_path.expanduser()) + ) + self.enable_generate_reference = request.config.getoption( + "--generate-reference" + ) + self.fname = f"{self.fname_prefix}.UNKNOWN_FORMAT" + + @property + def module_name(self): + return self.request.node.module.__name__ + + @property + def test_name(self): + return self.request.node.name + + @property + def fname_prefix(self): + double_under = re.compile(r"[:\[\]{}]") + no_space = re.compile(r'[,"\']') # quotes and commas + + name = double_under.sub("__", self.test_name) + name = no_space.sub("", name) + return name + + @property + def relative_regression_data_dir(self): + relative_data_dir = Path(self.module_name.replace(".", "/")) + if self.request.cls is not None: + relative_data_dir /= HDFWriterMixin.convert_to_snake_case( + self.request.cls.__name__ + ) + return relative_data_dir + + @property + def absolute_regression_data_dir(self): + return self.regression_data_path / self.relative_regression_data_dir + + @property + def fpath(self): + return self.absolute_regression_data_dir / self.fname + + def sync_dataframe(self, data, key="data"): + """ + Synchronizes the dataframe with the regression data. + + Parameters + ---------- + data : DataFrame + The dataframe to be synchronized. + key : str, optional + The key to use for storing the dataframe in the regression data file. Defaults to "data". + + Returns + ------- + DataFrame or None + The synchronized dataframe if `enable_generate_reference` is `False`, otherwise `None`. + """ + self.fname = f"{self.fname_prefix}.h5" + if self.enable_generate_reference: + self.fpath.parent.mkdir(parents=True, exist_ok=True) + data.to_hdf( + self.fpath, + key=key, + ) + pytest.skip("Skipping test to generate reference data") + else: + return pd.read_hdf(self.fpath, key=key) + + def sync_ndarray(self, data): + """ + Synchronizes the ndarray with the regression data. + + Parameters + ---------- + data : ndarray + The ndarray to be synchronized. + + Returns + ------- + ndarray or None + The synchronized ndarray if `enable_generate_reference` is `False`, otherwise `None`. + """ + self.fname = f"{self.fname_prefix}.npy" + if self.enable_generate_reference: + self.fpath.parent.mkdir(parents=True, exist_ok=True) + self.fpath.parent.mkdir(parents=True, exist_ok=True) + np.save(self.fpath, data) + pytest.skip("Skipping test to generate reference data") + else: + return np.load(self.fpath) + + def sync_str(self, data): + """ + Synchronizes the string with the regression data. + + Parameters + ---------- + data : str + The string to be synchronized. + + Returns + ------- + str or None + The synchronized string if `enable_generate_reference` is `False`, otherwise `None`. + """ + self.fname = f"{self.fname_prefix}.txt" + if self.enable_generate_reference: + self.fpath.parent.mkdir(parents=True, exist_ok=True) + with self.fpath.open("w") as fh: + fh.write(data) + pytest.skip(f"Skipping test to generate regression_data {self.fpath} data") + else: + with self.fpath.open("r") as fh: + return fh.read() + + def sync_hdf_store(self, stardis_module, update_fname=True): + """ + Synchronizes the HDF store with the regression data. + + Parameters + ---------- + stardis_module : object + The module to be synchronized. + update_fname : bool, optional + Whether to update the file name. Defaults to True. + + Returns + ------- + HDFStore or None + The synchronized HDF store if `enable_generate_reference` is `False`, otherwise `None`. + """ + if update_fname: + self.fname = f"{self.fname_prefix}.h5" + if self.enable_generate_reference: + self.fpath.parent.mkdir(parents=True, exist_ok=True) + with pd.HDFStore(self.fpath, mode="w") as store: + stardis_module.to_hdf(store, overwrite=True) + pytest.skip(f"Skipping test to generate regression data: {self.fpath}") + else: + return pd.HDFStore(self.fpath, mode="r") + + +@pytest.fixture(scope="function") +def regression_data(request): + return RegressionData(request)