diff --git a/.gitignore b/.gitignore index 375de91..cd150e7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ __pycache__ .pytest_cache +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md index f8aa5f2..e81853a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Checks ISMIP7 NetCDF simulation datasets for compliance with the [ISMIP7 data re 4. **Time** — time dimension is present, unlimited, and monotonically increasing; annual cadence for `x,y,t` and `t` variables; sparse snapshot timestamps for `x,y,z,t` variables (see [Time encoding](#time-encoding)); experiment start/end dates and duration match `experiments_ismip7.csv` for `x,y,t` and `t` variables. 5. **Attributes** — required global and coordinate attributes are present and have correct values; `standard_name` matches data request; `_FillValue` equals the NetCDF4 default for the variable's dtype; variable and time are float32; `scale_factor` and `add_offset` are not allowed. -Compliance criteria are defined in `conventions/ISMIP7_variable_request.csv` (variable metadata) and `experiments_ismip7.csv` (valid experiment year ranges and durations). +Compliance criteria are defined in `compliance_checker/data/ISMIP7_variable_request.csv` (variable metadata) and `compliance_checker/data/experiments_ismip7.csv` (valid experiment year ranges and durations). These files are bundled with the package. --- @@ -44,28 +44,33 @@ Reference lookup tables are available in the companion repository [`ismip7-time- ## Setup +Create the conda environment and install the package: + ```bash conda env create -f isschecker_env.yml conda activate isschecker +pip install . ``` +Installing the package registers the `ismip7-compliance-checker` command and bundles the data files, so the checker can be run from any directory. For development, install in editable mode with the test extra: `pip install -e ".[test]"`. + Dependencies: Python 3.14, `numpy` 2.4, `pandas` 3.0, `xarray` 2026.4, `cftime` 1.6, `netCDF4` 1.7, `tqdm` 4.67. --- ## Running the checker -The script must be run from the repository root. It writes `compliance_checker_log.txt` into the `--source-path` directory. +Once installed, run the checker from any directory with the `ismip7-compliance-checker` command (equivalently, `python -m compliance_checker`). It writes `compliance_checker_log.txt` into the `--source-path` directory. ```bash # Check x,y,t (3D spatial) variables -python compliance_checker.py --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7_xyt +ismip7-compliance-checker --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7_xyt # Check scalar (time-only) variables -python compliance_checker.py --source-path ./Models/AIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7_scalars +ismip7-compliance-checker --source-path ./Models/AIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7_scalars # Check both -python compliance_checker.py --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7 +ismip7-compliance-checker --source-path ./Models/GrIS/ISMIP7/SYNTH1/CORE/C001 --variable-list ismip7 ``` | Option | Default | Description | diff --git a/compliance_checker.py b/compliance_checker/__init__.py similarity index 99% rename from compliance_checker.py rename to compliance_checker/__init__.py index 44a149c..b59e8eb 100755 --- a/compliance_checker.py +++ b/compliance_checker/__init__.py @@ -42,6 +42,7 @@ import re import subprocess import argparse +from importlib import resources import numpy as np import pandas as pd @@ -54,10 +55,15 @@ DEFAULT_VARIABLE_LIST = "ismip7_scalars" VARIABLE_LIST_CHOICES = ("ismip7_scalars", "ismip7_xyt", "ismip7") -VARIABLE_REQUEST_CSV = os.path.join("conventions", "ISMIP7_variable_request.csv") +VARIABLE_REQUEST_CSV = "ISMIP7_variable_request.csv" EXPERIMENTS_ISMIP7_CSV_FILENAME = "experiments_ismip7.csv" + +def data_path(filename: str) -> str: + """Return the filesystem path to a bundled data file in ``compliance_checker.data``.""" + return str(resources.files("compliance_checker.data").joinpath(filename)) + AIS_GRID_EXTENT = [-3040000, -3040000, 3040000, 3040000] GrIS_GRID_EXTENT = [-720000, -3450000, 960000, -570000] AIS_POSSIBLE_RESOLUTION = [2, 4, 8, 16, 32] @@ -138,22 +144,19 @@ def main() -> None: run_checker( source_path=source_path, variable_list=variable_list, - workdir=os.getcwd(), ) def run_checker( source_path: str, variable_list: str = DEFAULT_VARIABLE_LIST, - workdir: str | None = None, commit_num: str | None = None, ): - workdir = os.path.abspath(workdir or os.getcwd()) commit_num = _get_commit_number() if commit_num is None else commit_num experiments_ismip7 = _load_experiments_csv( - os.path.join(workdir, EXPERIMENTS_ISMIP7_CSV_FILENAME) + data_path(EXPERIMENTS_ISMIP7_CSV_FILENAME) ) - ismip_meta, ismip_var, mandatory_variables = _load_criteria(workdir, variable_list) + ismip_meta, ismip_var, mandatory_variables = _load_criteria(variable_list) summary = _run_compliance_checker( source_path=source_path, @@ -205,8 +208,8 @@ def _parse_args() -> argparse.Namespace: return parser.parse_args() -def _load_criteria(workdir: str, variable_list: str): - csv_path = os.path.join(workdir, VARIABLE_REQUEST_CSV) +def _load_criteria(variable_list: str): + csv_path = data_path(VARIABLE_REQUEST_CSV) try: df = pd.read_csv(csv_path) except IOError: @@ -1511,7 +1514,3 @@ def _insert_synthesis( with open(os.path.join(source_path, "compliance_checker_log.txt"), "w") as f: f.writelines(contents) - - -if __name__ == "__main__": - main() diff --git a/conventions/ISMIP7_variable_request.csv b/compliance_checker/data/ISMIP7_variable_request.csv similarity index 100% rename from conventions/ISMIP7_variable_request.csv rename to compliance_checker/data/ISMIP7_variable_request.csv diff --git a/experiments_ismip7.csv b/compliance_checker/data/experiments_ismip7.csv similarity index 100% rename from experiments_ismip7.csv rename to compliance_checker/data/experiments_ismip7.csv diff --git a/generate/README.md b/generate/README.md index ea7b15b..a141673 100644 --- a/generate/README.md +++ b/generate/README.md @@ -76,6 +76,6 @@ python generate/generate_test_files.py --grid GrIS_16000m --scenario ctrl \ ## Notes -- Variable metadata is read from `conventions/ISMIP7_variable_request.csv`; grid definitions from the top-level `gdfs/` directory. +- Variable metadata is read from `compliance_checker/data/ISMIP7_variable_request.csv`; grid definitions from the top-level `gdfs/` directory. - `group`, `model`, `contact_name`, and `contact_email` are hardcoded in `create_netcdf_file()` to synthetic defaults — edit there to customise. - Generated files are synthetic and intended for testing the compliance checker, not for scientific use. diff --git a/generate/generate_test_files.py b/generate/generate_test_files.py index f05f1f8..08b45d1 100644 --- a/generate/generate_test_files.py +++ b/generate/generate_test_files.py @@ -12,6 +12,11 @@ from pathlib import Path import netCDF4 +REPO_ROOT = Path(__file__).resolve().parent.parent +GDF_DIR = REPO_ROOT / 'gdfs' +# The variable-request CSV is bundled with the compliance_checker package data. +CONVENTIONS_DIR = REPO_ROOT / 'compliance_checker' / 'data' + def get_available_grids(conventions_dir): """ @@ -27,8 +32,8 @@ def get_available_grids(conventions_dir): dict Dictionary with grid info: {'GrIS': [...], 'AIS': [...]} """ - # Grid definitions moved to project root `gdfs` directory - gdf_dir = Path(conventions_dir).parent / 'gdfs' + # Grid definitions live in the project root `gdfs` directory + gdf_dir = GDF_DIR grids = {'GrIS': [], 'AIS': []} if not gdf_dir.exists(): @@ -221,11 +226,9 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st contact_names='Your Name' contact_emails='your@email.org' - # Determine conventions directory + # Determine conventions directory (bundled package data by default) if conventions_dir is None: - # Try to find conventions directory relative to this script - script_dir = Path(__file__).parent.parent - conventions_dir = script_dir / 'conventions' + conventions_dir = CONVENTIONS_DIR conventions_dir = Path(conventions_dir) @@ -252,7 +255,7 @@ def create_netcdf_file(output_file, grid_name='GrIS_16000m', scenario='ctrl', st output_dir.mkdir(parents=True, exist_ok=True) # Load grid definition - gdf_file = conventions_dir.parent / 'gdfs' / f'gdf_ISMIP7_{grid_type}_{resolution}.txt' + gdf_file = GDF_DIR / f'gdf_ISMIP7_{grid_type}_{resolution}.txt' if not gdf_file.exists(): raise FileNotFoundError(f"Grid definition file not found: {gdf_file}") @@ -755,9 +758,7 @@ def create_multiple_files(output_dir=None, n_files=3, conventions_dir=None, """ if conventions_dir is None: - # Try to find conventions directory relative to this script - script_dir = Path(__file__).parent.parent - conventions_dir = script_dir / 'conventions' + conventions_dir = CONVENTIONS_DIR # Get available grids grids = get_available_grids(str(conventions_dir)) @@ -794,8 +795,7 @@ def create_multiple_files(output_dir=None, n_files=3, conventions_dir=None, import argparse # Get available grids - script_dir = Path(__file__).parent.parent - conventions_dir = script_dir / 'conventions' + conventions_dir = CONVENTIONS_DIR available_grids = get_available_grids(str(conventions_dir)) # Create list of available grid choices, excluding some high-resolution entries diff --git a/isschecker_env.yml b/isschecker_env.yml index 2823a79..63a5933 100644 --- a/isschecker_env.yml +++ b/isschecker_env.yml @@ -2,16 +2,16 @@ name: isschecker channels: - conda-forge dependencies: - - python=3.14 - - pip=26.0.1 + - python + - pip # Core scientific python - - numpy=2.4.3 - - pandas=3.0.2 - - pytest=8.4.2 - - tqdm=4.67.3 + - numpy + - pandas + - pytest + - tqdm # Spatial packages - - xarray=2026.4.0 - - cftime=1.6.5 - - netcdf4=1.7.4 + - xarray + - cftime + - netcdf4 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..23f7aa9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "isschecker" +version = "0.1.0" +description = "ISMIP7 compliance checker for ice sheet model simulation NetCDF datasets" +readme = "README.md" +license = { file = "LICENSE" } +requires-python = ">=3.10" +authors = [{ name = "Heiko Goelzer", email = "heig@norceresearch.no" }] +dependencies = [ + "numpy", + "pandas", + "xarray", + "cftime", + "netCDF4", + "tqdm", +] + +[project.optional-dependencies] +test = ["pytest"] + +[project.scripts] +ismip7-compliance-checker = "compliance_checker:main" + +[tool.setuptools] +packages = ["compliance_checker", "compliance_checker.data"] + +[tool.setuptools.package-data] +"compliance_checker.data" = ["*.csv"] diff --git a/tests/test_compliance_checker.py b/tests/test_compliance_checker.py index 886af24..a95f2d2 100644 --- a/tests/test_compliance_checker.py +++ b/tests/test_compliance_checker.py @@ -37,7 +37,6 @@ def baseline_core_dir(tmp_path_factory): scenario="historical", start_year=2013, nyears=2, - conventions_dir=REPO_ROOT / "conventions", include_scalars=True, include_xyt=False, output_root=baseline_root, @@ -48,7 +47,6 @@ def baseline_core_dir(tmp_path_factory): baseline_summary = compliance_checker.run_checker( source_path=str(core_dir), variable_list="ismip7_scalars", - workdir=str(REPO_ROOT), commit_num="tests", ) assert baseline_summary["total_errors"] == 0, ( @@ -69,7 +67,6 @@ def run_checker(case_dir: Path): return compliance_checker.run_checker( source_path=str(case_dir), variable_list="ismip7_scalars", - workdir=str(REPO_ROOT), commit_num="tests", )