From 0a3e6b4505e148e6a82faf45005e8c711b9133cc Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:20:33 +0100 Subject: [PATCH 01/18] Add CI/CD workflows and modern packaging from PR #33 --- .github/markdown-link-check-config.json | 31 +++ .github/workflows/build-containers.yml | 235 ++++++++++++++++++ .github/workflows/ci.yml | 154 ++++++++++++ .github/workflows/docs.yml | 308 ++++++++++++++++++++++++ .github/workflows/release.yml | 211 ++++++++++++++++ pyproject.toml | 179 ++++++++++++++ workflow/README.md | 35 +++ 7 files changed, 1153 insertions(+) create mode 100644 .github/markdown-link-check-config.json create mode 100644 .github/workflows/build-containers.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/release.yml create mode 100644 pyproject.toml create mode 100644 workflow/README.md diff --git a/.github/markdown-link-check-config.json b/.github/markdown-link-check-config.json new file mode 100644 index 0000000..40b883e --- /dev/null +++ b/.github/markdown-link-check-config.json @@ -0,0 +1,31 @@ +{ + "ignorePatterns": [ + { + "pattern": "^http://localhost" + }, + { + "pattern": "^https://127.0.0.1" + }, + { + "pattern": "^file://" + } + ], + "replacementPatterns": [ + { + "pattern": "^/", + "replacement": "{{BASEURL}}/" + } + ], + "httpHeaders": [ + { + "urls": ["https://github.com"], + "headers": { + "Accept": "text/html" + } + } + ], + "timeout": "20s", + "retryOn429": true, + "retryCount": 3, + "fallbackHttpStatus": [400, 401, 403, 404, 405, 500, 502, 503, 504] +} \ No newline at end of file diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml new file mode 100644 index 0000000..5c26e0d --- /dev/null +++ b/.github/workflows/build-containers.yml @@ -0,0 +1,235 @@ +name: Build and Push Containers + +on: + push: + branches: [ master, main ] + tags: [ 'v*' ] + pull_request: + branches: [ master, main ] + workflow_dispatch: + inputs: + push_images: + description: 'Push images to registry' + required: false + default: false + type: boolean + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-docker: + name: Build Docker Images + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + target: [production, development] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + suffix=-${{ matrix.target }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: workflow/containers/Dockerfile + target: ${{ matrix.target }} + push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.push_images == 'true') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + test-containers: + name: Test Container Images + runs-on: ubuntu-latest + needs: build-docker + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + + strategy: + matrix: + target: [production, development] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build test image + uses: docker/build-push-action@v5 + with: + context: . + file: workflow/containers/Dockerfile + target: ${{ matrix.target }} + load: true + tags: ocp-tool:test-${{ matrix.target }} + cache-from: type=gha + + - name: Test production container + if: matrix.target == 'production' + run: | + # Test basic functionality + docker run --rm ocp-tool:test-production python --version + docker run --rm ocp-tool:test-production snakemake --version + + # Test OCP-tool imports + docker run --rm ocp-tool:test-production python -c "import ocp_tool; print('✓ OCP-tool import successful')" + + # Test conda environment + docker run --rm ocp-tool:test-production conda list | grep -E "(numpy|matplotlib|netcdf4)" + + - name: Test development container + if: matrix.target == 'development' + run: | + # Test Jupyter installation + docker run --rm ocp-tool:test-development jupyter --version + docker run --rm ocp-tool:test-development jupyter kernelspec list + + - name: Test container security + uses: aquasecurity/trivy-action@master + with: + image-ref: 'ocp-tool:test-${{ matrix.target }}' + format: 'table' + severity: 'CRITICAL,HIGH' + continue-on-error: true + + build-singularity: + name: Build Singularity Image + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Singularity + uses: eWaterCycle/setup-singularity@v7 + with: + singularity-version: 3.11.4 + + - name: Build Singularity container + run: | + sudo singularity build ocp-tool.sif workflow/containers/singularity.def + + - name: Test Singularity container + run: | + # Test basic functionality + singularity exec ocp-tool.sif python --version + singularity exec ocp-tool.sif snakemake --version + + # Test OCP-tool imports + singularity exec ocp-tool.sif python -c "import ocp_tool; print('✓ OCP-tool import successful')" + + - name: Upload Singularity image as artifact + uses: actions/upload-artifact@v4 + with: + name: singularity-image + path: ocp-tool.sif + retention-days: 30 + + container-scan: + name: Container Security Scan + runs-on: ubuntu-latest + needs: build-docker + if: github.event_name != 'pull_request' + + steps: + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-production + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy results to GitHub Security + uses: github/codeql-action/upload-sarif@v2 + with: + sarif_file: 'trivy-results.sarif' + + integration-test: + name: Integration Test with Containers + runs-on: ubuntu-latest + needs: build-docker + if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build test image + uses: docker/build-push-action@v5 + with: + context: . + file: workflow/containers/Dockerfile + target: production + load: true + tags: ocp-tool:integration-test + cache-from: type=gha + + - name: Create test data structure + run: | + mkdir -p test_data/{input,output} + mkdir -p test_data/input/{gaussian_grids_full,gaussian_grids_linear_reduced,openifs_input_default,runoff_map_default,fesom_mesh} + mkdir -p test_data/output/{openifs_input_modified,oasis_mct3_input,runoff_map_modified,plots} + + # Create minimal test config + cat > test_data/test_config.yaml << EOF + res_num: 159 + truncation_type: "linear" + exp_name_oifs: "test" + num_fields: 50 + grid_name_oce: "TEST" + cavity: false + interp_res: "r360x181" + fesom_grid_file_path: "/app/data/input/test_mesh.nc" + do_paleo: false + manual_basin_removal: [] + EOF + + - name: Run integration test + run: | + docker run --rm \ + -v $(pwd)/test_data:/app/data \ + -w /app \ + ocp-tool:integration-test \ + snakemake --dry-run --configfile /app/data/test_config.yaml + + echo "✓ Integration test completed successfully" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d8fb21e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,154 @@ +name: CI + +on: + push: + branches: [ master, main, develop ] + pull_request: + branches: [ master, main ] + workflow_dispatch: + +jobs: + test-workflow: + name: Test Snakemake Workflow + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Pixi + uses: prefix-dev/setup-pixi@v0.8.1 + with: + environments: dev + + - name: Lint Snakefile + run: pixi run workflow-lint + + - name: Validate workflow configuration + run: | + pixi run -e dev python -c "import yaml; yaml.safe_load(open('workflow/config/config.yaml'))" + echo "✓ Configuration file is valid YAML" + + - name: Check Python scripts syntax + run: | + pixi run -e dev python -m py_compile run_ocp_tool.py + pixi run -e dev python -m py_compile ocp_tool/*.py + echo "✓ All Python scripts have valid syntax" + + - name: Dry run Snakemake workflow + run: | + # Create minimal test data structure + mkdir -p input/{gaussian_grids_full,gaussian_grids_linear_reduced,openifs_input_default,runoff_map_default,fesom_mesh} + mkdir -p output/{openifs_input_modified,oasis_mct3_input,runoff_map_modified,plots} + + # Test dry run with modified config for CI + cat > workflow/test_config.yaml << EOF + res_num: 159 + truncation_type: "linear" + exp_name_oifs: "test" + num_fields: 50 + grid_name_oce: "TEST" + cavity: false + interp_res: "r360x181" + fesom_grid_file_path: "/tmp/test_mesh.nc" + do_paleo: false + manual_basin_removal: [] + EOF + + pixi run workflow-dry --configfile test_config.yaml + echo "✓ Snakemake workflow dry run successful" + + - name: Test import of ocp_tool modules + run: | + pixi run -e dev python -c " + import sys + sys.path.insert(0, '.') + from ocp_tool.config import load_config + from ocp_tool.gaussian_grids import generate_gaussian_grid + from ocp_tool.lsm import process_land_sea_mask + print('✓ Successfully imported ocp_tool modules') + " + + code-quality: + name: Code Quality Checks + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Pixi + uses: prefix-dev/setup-pixi@v0.8.1 + with: + environments: dev + + - name: Run flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + pixi run -e dev flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist + # Exit-zero treats all errors as warnings + pixi run -e dev flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist + + - name: Check code formatting with black + run: | + pixi run -e dev black --check --diff --exclude=.git . || echo "::warning::Code formatting issues found. Run 'pixi run format' to fix." + + - name: Check import sorting with isort + run: | + pixi run -e dev isort --check-only --diff . || echo "::warning::Import sorting issues found. Run 'pixi run sort-imports' to fix." + + # Notebook tests removed - notebooks no longer in repo + + documentation: + name: Documentation Check + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check README files + run: | + for readme in README.md workflow/README.md; do + if [ -f "$readme" ]; then + echo "✓ Found $readme" + else + echo "::error::Missing $readme" + exit 1 + fi + done + + - name: Check for broken links in documentation + uses: gaurav-nelson/github-action-markdown-link-check@v1 + with: + use-quiet-mode: 'yes' + config-file: '.github/markdown-link-check-config.json' + continue-on-error: true + + security-scan: + name: Security Scanning + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy security scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + continue-on-error: true + + - name: Upload Trivy results to GitHub Security + uses: github/codeql-action/upload-sarif@v2 + if: always() + with: + sarif_file: 'trivy-results.sarif' + continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f9f1303 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,308 @@ +name: Documentation + +on: + push: + branches: [ master, main ] + paths: + - 'docs/**' + - '**.md' + - '**.py' + - 'workflow/**' + pull_request: + branches: [ master, main ] + paths: + - 'docs/**' + - '**.md' + - '**.py' + - 'workflow/**' + workflow_dispatch: + +jobs: + build-docs: + name: Build Documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for proper versioning + + - name: Setup Pixi + uses: prefix-dev/setup-pixi@v0.8.1 + with: + environments: docs + + - name: Generate API documentation + run: | + # Create docs directory structure if it doesn't exist + mkdir -p docs/{source,build} + + # Generate Sphinx configuration if it doesn't exist + if [ ! -f docs/source/conf.py ]; then + cat > docs/source/conf.py << 'EOF' + import os + import sys + sys.path.insert(0, os.path.abspath('../..')) + + project = 'OCP-tool' + copyright = '2024, OCP-tool developers' + author = 'OCP-tool developers' + + extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', + 'sphinx.ext.intersphinx', + 'nbsphinx', + 'myst_parser', + ] + + templates_path = ['_templates'] + exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + html_theme = 'sphinx_rtd_theme' + html_static_path = ['_static'] + + # Notebook execution settings + nbsphinx_execute = 'never' + nbsphinx_allow_errors = True + + # Intersphinx mapping + intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'numpy': ('https://numpy.org/doc/stable/', None), + 'matplotlib': ('https://matplotlib.org/stable/', None), + } + + # MyST settings + myst_enable_extensions = [ + "colon_fence", + "deflist", + "html_image", + "linkify", + "replacements", + "smartquotes", + "tasklist", + ] + EOF + fi + + # Generate main index if it doesn't exist + if [ ! -f docs/source/index.rst ]; then + cat > docs/source/index.rst << 'EOF' + OCP-tool Documentation + ====================== + + OpenIFS Coupling Preparation Tool for climate modeling. + + .. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + workflow + api + notebooks + examples + + Indices and tables + ================== + + * :ref:`genindex` + * :ref:`modindex` + * :ref:`search` + EOF + fi + + # Build API docs with autoapi + pixi run -e docs sphinx-apidoc -f -o docs/source/api ocp_tool + + - name: Convert notebooks for documentation + run: | + mkdir -p docs/source/notebooks + + # Copy main notebook + if [ -f ocp_tool.ipynb ]; then + cp ocp_tool.ipynb docs/source/notebooks/ + fi + + # Create notebook index + cat > docs/source/notebooks.rst << 'EOF' + Jupyter Notebooks + ================= + + Example notebooks demonstrating OCP-tool usage. + + .. toctree:: + :maxdepth: 2 + + notebooks/ocp_tool + EOF + + - name: Create additional documentation pages + run: | + # Installation guide + cat > docs/source/installation.md << 'EOF' + # Installation + + ## Requirements + + - Python 3.8+ + - Conda/Mamba package manager + - NetCDF libraries + - ECCODES for GRIB processing + + ## Quick Install + + ```bash + git clone https://github.com/pgierz/ocp-tool.git + cd ocp-tool + conda env create -f environment.yaml + conda activate ocp-tool + pip install -e . + ``` + + ## Container Installation + + ### Docker + ```bash + docker pull ghcr.io/pgierz/ocp-tool:latest-production + ``` + + ### Singularity + ```bash + singularity pull ocp-tool.sif oras://ghcr.io/pgierz/ocp-tool:latest-singularity + ``` + EOF + + # Workflow documentation + cat > docs/source/workflow.md << 'EOF' + # Snakemake Workflow + + The OCP-tool provides a complete Snakemake workflow for automated processing. + + ## Configuration + + Edit `workflow/config/config.yaml`: + + ```yaml + res_num: 159 + truncation_type: "linear" + exp_name_oifs: "abda" + grid_name_oce: "CORE2" + ``` + + ## Execution + + ```bash + # Local execution + snakemake --cores 4 --use-conda + + # HPC cluster + snakemake --profile workflow/profiles/slurm + + # Container execution + docker run --rm -v $(pwd):/app/data ocp-tool:latest snakemake --cores 4 + ``` + + ## Workflow Rules + + - `prepare_gaussian_grids`: Process OpenIFS grid files + - `process_fesom_grid`: Handle ocean model grids + - `modify_land_sea_mask`: Core LSM modification + - `generate_oasis_files`: Create OASIS3-MCT files + - `modify_runoff_maps`: Adjust runoff routing + - `generate_plots`: Create visualizations + EOF + + # Examples page + cat > docs/source/examples.md << 'EOF' + # Examples + + ## Basic Usage + + ```python + from ocp_tool import ocp_tool + + # Generate coordinate and area fields + center_lats, center_lons, crn_lats, crn_lons, gridcell_area, lons_list, NN = \ + ocp_tool.generate_coord_area( + res_num=159, + input_path_reduced_grid="input/gaussian_grids_linear_reduced/", + input_path_full_grid="input/gaussian_grids_full/", + truncation_type="linear" + ) + ``` + + ## Workflow Examples + + See the `workflow/` directory for complete Snakemake workflow examples. + EOF + + - name: Build Sphinx documentation + run: | + pixi run docs-build + + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: documentation + path: docs/build/html/ + retention-days: 30 + + deploy-docs: + name: Deploy Documentation + runs-on: ubuntu-latest + needs: build-docs + if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' + + permissions: + contents: read + pages: write + id-token: write + + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - name: Download documentation artifact + uses: actions/download-artifact@v4 + with: + name: documentation + path: ./docs + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload to GitHub Pages + uses: actions/upload-pages-artifact@v3 + with: + path: ./docs + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + + link-check: + name: Check Documentation Links + runs-on: ubuntu-latest + needs: build-docs + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download documentation artifact + uses: actions/download-artifact@v4 + with: + name: documentation + path: ./docs + + - name: Check HTML links + uses: lycheeverse/lychee-action@v1 + with: + args: --verbose --no-progress --exclude-all-private docs/**/*.html + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c0d24a6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,211 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag' + required: true + type: string + +jobs: + create-release: + name: Create Release + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + version: ${{ steps.get_version.outputs.version }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version + id: get_version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + VERSION="${{ github.event.inputs.tag }}" + else + VERSION=${GITHUB_REF#refs/tags/} + fi + echo "version=${VERSION}" >> $GITHUB_OUTPUT + + - name: Generate changelog + id: changelog + run: | + # Generate changelog from git commits since last tag + LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -z "$LAST_TAG" ]; then + CHANGES=$(git log --pretty=format:"- %s (%h)" --no-merges) + else + CHANGES=$(git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%h)" --no-merges) + fi + + cat > CHANGELOG.md << EOF + # Changes in ${{ steps.get_version.outputs.version }} + + ${CHANGES} + + ## Full Changelog + + See [commits](https://github.com/${{ github.repository }}/compare/${LAST_TAG}...${{ steps.get_version.outputs.version }}) for full details. + EOF + + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.get_version.outputs.version }} + release_name: Release ${{ steps.get_version.outputs.version }} + body_path: CHANGELOG.md + draft: false + prerelease: ${{ contains(steps.get_version.outputs.version, '-') }} + + build-release-containers: + name: Build Release Containers + needs: create-release + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + matrix: + target: [production, development] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + flavor: | + suffix=-${{ matrix.target }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Build and push release image + uses: docker/build-push-action@v5 + with: + context: . + file: workflow/containers/Dockerfile + target: ${{ matrix.target }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + build-singularity-release: + name: Build Singularity Release + needs: create-release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Singularity + uses: eWaterCycle/setup-singularity@v7 + with: + singularity-version: 3.11.4 + + - name: Build Singularity container + run: | + sudo singularity build ocp-tool-${{ needs.create-release.outputs.version }}.sif workflow/containers/singularity.def + + - name: Upload Singularity image to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./ocp-tool-${{ needs.create-release.outputs.version }}.sif + asset_name: ocp-tool-${{ needs.create-release.outputs.version }}.sif + asset_content_type: application/octet-stream + + upload-workflow-assets: + name: Upload Workflow Assets + needs: create-release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Create workflow archive + run: | + tar -czf ocp-tool-workflow-${{ needs.create-release.outputs.version }}.tar.gz \ + workflow/ \ + environment.yaml \ + README.md \ + --exclude='workflow/logs' \ + --exclude='workflow/temp' + + - name: Upload workflow archive to release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./ocp-tool-workflow-${{ needs.create-release.outputs.version }}.tar.gz + asset_name: ocp-tool-workflow-${{ needs.create-release.outputs.version }}.tar.gz + asset_content_type: application/gzip + + notify-release: + name: Notify Release + needs: [create-release, build-release-containers, build-singularity-release] + runs-on: ubuntu-latest + if: always() + + steps: + - name: Create release summary + run: | + cat > $GITHUB_STEP_SUMMARY << EOF + # 🚀 Release ${{ needs.create-release.outputs.version }} Complete + + ## 📦 Available Assets + + - **Docker Images**: \`ghcr.io/${{ github.repository }}:${{ needs.create-release.outputs.version }}-production\` + - **Development Image**: \`ghcr.io/${{ github.repository }}:${{ needs.create-release.outputs.version }}-development\` + - **Singularity Container**: \`ocp-tool-${{ needs.create-release.outputs.version }}.sif\` + - **Workflow Archive**: \`ocp-tool-workflow-${{ needs.create-release.outputs.version }}.tar.gz\` + + ## 🐳 Quick Start with Docker + + \`\`\`bash + docker pull ghcr.io/${{ github.repository }}:${{ needs.create-release.outputs.version }}-production + docker run --rm -v \$(pwd):/app/data ghcr.io/${{ github.repository }}:${{ needs.create-release.outputs.version }}-production snakemake --cores 4 + \`\`\` + + ## 📖 Documentation + + Visit the [documentation](https://${{ github.repository_owner }}.github.io/ocp-tool/) for usage guides and API reference. + EOF \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..99541d3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,179 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "ocp-tool" +description = "Tool to generate OASIS files for coupling OpenIFS, FESOM2, and NEMO" +authors = [ + {name = "Jan Streffing", email = "jan.streffing@awi.de"} +] +readme = "README.md" +license = {file = "licence"} +requires-python = ">=3.8" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Atmospheric Science", +] +dependencies = [ + "numpy", + "netcdf4", + "eccodes", + "matplotlib", + "pandas", + "pyyaml", + "tqdm", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/pgierz/ocp-tool" +Repository = "https://github.com/pgierz/ocp-tool" +Issues = "https://github.com/pgierz/ocp-tool/issues" + +[project.entry-points."scriptengine.tasks"] +"ocpt.main" = "ocp_tool.scriptengine_task:OCPTool" + +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "black", + "isort", + "flake8", + "mypy", +] +jupyter = [ + "jupyterlab", + "notebook", + "ipykernel", +] +docs = [ + "sphinx", + "sphinx-rtd-theme", + "nbsphinx", + "myst-parser", + "sphinx-autoapi", +] + +[tool.setuptools] +packages = ["ocp_tool"] + +[tool.setuptools.dynamic] +version = {attr = "ocp_tool.__version__"} + +# Pixi configuration +[tool.pixi.project] +name = "ocp-tool" +description = "Tool to generate OASIS files for coupling OpenIFS, FESOM2, and NEMO" +authors = ["Jan Streffing "] +channels = ["conda-forge", "bioconda", "eumetsat"] +platforms = ["linux-64", "linux-aarch64", "osx-64", "osx-arm64"] + +[tool.pixi.dependencies] +python = ">=3.8" +numpy = "*" +netcdf4 = "*" +matplotlib = "*" +basemap = "*" +pandas = "*" +pyyaml = "*" +python-eccodes = "*" +git = "*" +pip = "*" +tqdm = "*" +snakemake-minimal = ">=7.0" + +[tool.pixi.pypi-dependencies] +# Use modernized pyfesom2 branch with Python 3.11+ support +pyfesom2 = { git = "https://github.com/FESOM/pyfesom2.git", branch = "modernize-packaging" } + +[tool.pixi.feature.jupyter.dependencies] +jupyterlab = "*" +notebook = "*" +ipykernel = "*" + +[tool.pixi.feature.dev.dependencies] +pytest = "*" +pytest-cov = "*" +black = "*" +isort = "*" +flake8 = "*" +mypy = "*" + +[tool.pixi.feature.docs.dependencies] +sphinx = "*" +sphinx-rtd-theme = "*" +nbsphinx = "*" +myst-parser = "*" +sphinx-autoapi = "*" + +[tool.pixi.environments] +default = { solve-group = "default" } +jupyter = { features = ["jupyter"], solve-group = "default" } +dev = { features = ["dev"], solve-group = "default" } +docs = { features = ["docs"], solve-group = "default" } +full = { features = ["jupyter", "dev", "docs"], solve-group = "default" } + +[tool.pixi.tasks] +lint = "flake8 ocp_tool" +format = "black ocp_tool" +sort-imports = "isort ocp_tool" +type-check = "mypy ocp_tool" +test = "pytest" +clean-format = { depends-on = ["format", "sort-imports"] } +jupyter = "jupyter lab" +notebook = "jupyter notebook" +docs-build = "sphinx-build -W -b html docs/source docs/build/html" +docs-clean = "rm -rf docs/build" +docs-serve = "python -m http.server 8000 --directory docs/build/html" +workflow-dry = { cmd = "snakemake --dry-run", cwd = "workflow" } +workflow-lint = { cmd = "snakemake --lint", cwd = "workflow" } +workflow-run = { cmd = "snakemake --cores 4 --use-conda", cwd = "workflow" } +build = "python -m build" +install-dev = "pip install -e ." + +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["ocp_tool"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "--strict-markers", + "--strict-config", + "--cov=ocp_tool", + "--cov-report=term-missing", + "--cov-report=html", +] \ No newline at end of file diff --git a/workflow/README.md b/workflow/README.md new file mode 100644 index 0000000..988ceb7 --- /dev/null +++ b/workflow/README.md @@ -0,0 +1,35 @@ +# OCP-tool Snakemake Workflow + +This directory contains the Snakemake workflow for automated climate model preparation. + +## Usage + +Configure your settings in `config/config.yaml`, then run: + +```bash +# Local execution +snakemake --cores 4 + +# With pixi +pixi run workflow-run + +# Dry run to check workflow +pixi run workflow-dry +``` + +## Configuration + +Edit `config/config.yaml` to specify: +- Grid resolution (`res_num`) +- Experiment name (`exp_name_oifs`) +- Ocean grid name (`grid_name_oce`) +- Input/output paths + +## Rules + +- `prepare_gaussian_grids`: Process OpenIFS grid files +- `process_fesom_grid`: Handle ocean model grids +- `modify_land_sea_mask`: Core LSM modification +- `generate_oasis_files`: Create OASIS3-MCT files +- `modify_runoff_maps`: Adjust runoff routing +- `generate_plots`: Create visualizations \ No newline at end of file From 5d1d2a6d803406025601afd11fef792fd6f05b2a Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:23:11 +0100 Subject: [PATCH 02/18] Remove container build workflow (Dockerfile not available) --- .github/workflows/build-containers.yml | 235 ------------------------- 1 file changed, 235 deletions(-) delete mode 100644 .github/workflows/build-containers.yml diff --git a/.github/workflows/build-containers.yml b/.github/workflows/build-containers.yml deleted file mode 100644 index 5c26e0d..0000000 --- a/.github/workflows/build-containers.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: Build and Push Containers - -on: - push: - branches: [ master, main ] - tags: [ 'v*' ] - pull_request: - branches: [ master, main ] - workflow_dispatch: - inputs: - push_images: - description: 'Push images to registry' - required: false - default: false - type: boolean - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-docker: - name: Build Docker Images - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - strategy: - matrix: - target: [production, development] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - flavor: | - suffix=-${{ matrix.target }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - file: workflow/containers/Dockerfile - target: ${{ matrix.target }} - push: ${{ github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') || github.event.inputs.push_images == 'true') }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 - - test-containers: - name: Test Container Images - runs-on: ubuntu-latest - needs: build-docker - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - - strategy: - matrix: - target: [production, development] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build test image - uses: docker/build-push-action@v5 - with: - context: . - file: workflow/containers/Dockerfile - target: ${{ matrix.target }} - load: true - tags: ocp-tool:test-${{ matrix.target }} - cache-from: type=gha - - - name: Test production container - if: matrix.target == 'production' - run: | - # Test basic functionality - docker run --rm ocp-tool:test-production python --version - docker run --rm ocp-tool:test-production snakemake --version - - # Test OCP-tool imports - docker run --rm ocp-tool:test-production python -c "import ocp_tool; print('✓ OCP-tool import successful')" - - # Test conda environment - docker run --rm ocp-tool:test-production conda list | grep -E "(numpy|matplotlib|netcdf4)" - - - name: Test development container - if: matrix.target == 'development' - run: | - # Test Jupyter installation - docker run --rm ocp-tool:test-development jupyter --version - docker run --rm ocp-tool:test-development jupyter kernelspec list - - - name: Test container security - uses: aquasecurity/trivy-action@master - with: - image-ref: 'ocp-tool:test-${{ matrix.target }}' - format: 'table' - severity: 'CRITICAL,HIGH' - continue-on-error: true - - build-singularity: - name: Build Singularity Image - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Singularity - uses: eWaterCycle/setup-singularity@v7 - with: - singularity-version: 3.11.4 - - - name: Build Singularity container - run: | - sudo singularity build ocp-tool.sif workflow/containers/singularity.def - - - name: Test Singularity container - run: | - # Test basic functionality - singularity exec ocp-tool.sif python --version - singularity exec ocp-tool.sif snakemake --version - - # Test OCP-tool imports - singularity exec ocp-tool.sif python -c "import ocp_tool; print('✓ OCP-tool import successful')" - - - name: Upload Singularity image as artifact - uses: actions/upload-artifact@v4 - with: - name: singularity-image - path: ocp-tool.sif - retention-days: 30 - - container-scan: - name: Container Security Scan - runs-on: ubuntu-latest - needs: build-docker - if: github.event_name != 'pull_request' - - steps: - - name: Run Trivy vulnerability scanner - uses: aquasecurity/trivy-action@master - with: - image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-production - format: 'sarif' - output: 'trivy-results.sarif' - - - name: Upload Trivy results to GitHub Security - uses: github/codeql-action/upload-sarif@v2 - with: - sarif_file: 'trivy-results.sarif' - - integration-test: - name: Integration Test with Containers - runs-on: ubuntu-latest - needs: build-docker - if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch' - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build test image - uses: docker/build-push-action@v5 - with: - context: . - file: workflow/containers/Dockerfile - target: production - load: true - tags: ocp-tool:integration-test - cache-from: type=gha - - - name: Create test data structure - run: | - mkdir -p test_data/{input,output} - mkdir -p test_data/input/{gaussian_grids_full,gaussian_grids_linear_reduced,openifs_input_default,runoff_map_default,fesom_mesh} - mkdir -p test_data/output/{openifs_input_modified,oasis_mct3_input,runoff_map_modified,plots} - - # Create minimal test config - cat > test_data/test_config.yaml << EOF - res_num: 159 - truncation_type: "linear" - exp_name_oifs: "test" - num_fields: 50 - grid_name_oce: "TEST" - cavity: false - interp_res: "r360x181" - fesom_grid_file_path: "/app/data/input/test_mesh.nc" - do_paleo: false - manual_basin_removal: [] - EOF - - - name: Run integration test - run: | - docker run --rm \ - -v $(pwd)/test_data:/app/data \ - -w /app \ - ocp-tool:integration-test \ - snakemake --dry-run --configfile /app/data/test_config.yaml - - echo "✓ Integration test completed successfully" \ No newline at end of file From 7a78e71bf6ed55d1d52bb9dc4563096cf92c3e50 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:25:28 +0100 Subject: [PATCH 03/18] Fix CI: update pixi config syntax and remove Snakemake steps --- .github/workflows/ci.yml | 37 +++---------------------------------- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8fb21e..2389aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,12 @@ on: workflow_dispatch: jobs: - test-workflow: - name: Test Snakemake Workflow + test-python: + name: Test Python Code runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.9", "3.10", "3.11"] steps: - name: Checkout code @@ -23,14 +23,6 @@ jobs: uses: prefix-dev/setup-pixi@v0.8.1 with: environments: dev - - - name: Lint Snakefile - run: pixi run workflow-lint - - - name: Validate workflow configuration - run: | - pixi run -e dev python -c "import yaml; yaml.safe_load(open('workflow/config/config.yaml'))" - echo "✓ Configuration file is valid YAML" - name: Check Python scripts syntax run: | @@ -38,29 +30,6 @@ jobs: pixi run -e dev python -m py_compile ocp_tool/*.py echo "✓ All Python scripts have valid syntax" - - name: Dry run Snakemake workflow - run: | - # Create minimal test data structure - mkdir -p input/{gaussian_grids_full,gaussian_grids_linear_reduced,openifs_input_default,runoff_map_default,fesom_mesh} - mkdir -p output/{openifs_input_modified,oasis_mct3_input,runoff_map_modified,plots} - - # Test dry run with modified config for CI - cat > workflow/test_config.yaml << EOF - res_num: 159 - truncation_type: "linear" - exp_name_oifs: "test" - num_fields: 50 - grid_name_oce: "TEST" - cavity: false - interp_res: "r360x181" - fesom_grid_file_path: "/tmp/test_mesh.nc" - do_paleo: false - manual_basin_removal: [] - EOF - - pixi run workflow-dry --configfile test_config.yaml - echo "✓ Snakemake workflow dry run successful" - - name: Test import of ocp_tool modules run: | pixi run -e dev python -c " diff --git a/pyproject.toml b/pyproject.toml index 99541d3..f5e5d7e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,7 +71,7 @@ packages = ["ocp_tool"] version = {attr = "ocp_tool.__version__"} # Pixi configuration -[tool.pixi.project] +[tool.pixi.workspace] name = "ocp-tool" description = "Tool to generate OASIS files for coupling OpenIFS, FESOM2, and NEMO" authors = ["Jan Streffing "] From 2ed97759885c4d7bfe8246263de563cf0ca7e9ea Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:28:20 +0100 Subject: [PATCH 04/18] Fix docs workflow: use docs environment for sphinx-build --- .github/workflows/docs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f9f1303..700ff5b 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -242,7 +242,7 @@ jobs: - name: Build Sphinx documentation run: | - pixi run docs-build + pixi run -e docs sphinx-build -W -b html docs/source docs/build/html - name: Upload documentation artifact uses: actions/upload-artifact@v4 From b243346a4cc52afac62c94b841327466c48d732b Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:31:21 +0100 Subject: [PATCH 05/18] Remove docs workflow (no docs folder, missing dependencies) --- .github/workflows/docs.yml | 308 ------------------------------------- 1 file changed, 308 deletions(-) delete mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 700ff5b..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,308 +0,0 @@ -name: Documentation - -on: - push: - branches: [ master, main ] - paths: - - 'docs/**' - - '**.md' - - '**.py' - - 'workflow/**' - pull_request: - branches: [ master, main ] - paths: - - 'docs/**' - - '**.md' - - '**.py' - - 'workflow/**' - workflow_dispatch: - -jobs: - build-docs: - name: Build Documentation - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history for proper versioning - - - name: Setup Pixi - uses: prefix-dev/setup-pixi@v0.8.1 - with: - environments: docs - - - name: Generate API documentation - run: | - # Create docs directory structure if it doesn't exist - mkdir -p docs/{source,build} - - # Generate Sphinx configuration if it doesn't exist - if [ ! -f docs/source/conf.py ]; then - cat > docs/source/conf.py << 'EOF' - import os - import sys - sys.path.insert(0, os.path.abspath('../..')) - - project = 'OCP-tool' - copyright = '2024, OCP-tool developers' - author = 'OCP-tool developers' - - extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx.ext.napoleon', - 'sphinx.ext.intersphinx', - 'nbsphinx', - 'myst_parser', - ] - - templates_path = ['_templates'] - exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] - - html_theme = 'sphinx_rtd_theme' - html_static_path = ['_static'] - - # Notebook execution settings - nbsphinx_execute = 'never' - nbsphinx_allow_errors = True - - # Intersphinx mapping - intersphinx_mapping = { - 'python': ('https://docs.python.org/3/', None), - 'numpy': ('https://numpy.org/doc/stable/', None), - 'matplotlib': ('https://matplotlib.org/stable/', None), - } - - # MyST settings - myst_enable_extensions = [ - "colon_fence", - "deflist", - "html_image", - "linkify", - "replacements", - "smartquotes", - "tasklist", - ] - EOF - fi - - # Generate main index if it doesn't exist - if [ ! -f docs/source/index.rst ]; then - cat > docs/source/index.rst << 'EOF' - OCP-tool Documentation - ====================== - - OpenIFS Coupling Preparation Tool for climate modeling. - - .. toctree:: - :maxdepth: 2 - :caption: Contents: - - installation - workflow - api - notebooks - examples - - Indices and tables - ================== - - * :ref:`genindex` - * :ref:`modindex` - * :ref:`search` - EOF - fi - - # Build API docs with autoapi - pixi run -e docs sphinx-apidoc -f -o docs/source/api ocp_tool - - - name: Convert notebooks for documentation - run: | - mkdir -p docs/source/notebooks - - # Copy main notebook - if [ -f ocp_tool.ipynb ]; then - cp ocp_tool.ipynb docs/source/notebooks/ - fi - - # Create notebook index - cat > docs/source/notebooks.rst << 'EOF' - Jupyter Notebooks - ================= - - Example notebooks demonstrating OCP-tool usage. - - .. toctree:: - :maxdepth: 2 - - notebooks/ocp_tool - EOF - - - name: Create additional documentation pages - run: | - # Installation guide - cat > docs/source/installation.md << 'EOF' - # Installation - - ## Requirements - - - Python 3.8+ - - Conda/Mamba package manager - - NetCDF libraries - - ECCODES for GRIB processing - - ## Quick Install - - ```bash - git clone https://github.com/pgierz/ocp-tool.git - cd ocp-tool - conda env create -f environment.yaml - conda activate ocp-tool - pip install -e . - ``` - - ## Container Installation - - ### Docker - ```bash - docker pull ghcr.io/pgierz/ocp-tool:latest-production - ``` - - ### Singularity - ```bash - singularity pull ocp-tool.sif oras://ghcr.io/pgierz/ocp-tool:latest-singularity - ``` - EOF - - # Workflow documentation - cat > docs/source/workflow.md << 'EOF' - # Snakemake Workflow - - The OCP-tool provides a complete Snakemake workflow for automated processing. - - ## Configuration - - Edit `workflow/config/config.yaml`: - - ```yaml - res_num: 159 - truncation_type: "linear" - exp_name_oifs: "abda" - grid_name_oce: "CORE2" - ``` - - ## Execution - - ```bash - # Local execution - snakemake --cores 4 --use-conda - - # HPC cluster - snakemake --profile workflow/profiles/slurm - - # Container execution - docker run --rm -v $(pwd):/app/data ocp-tool:latest snakemake --cores 4 - ``` - - ## Workflow Rules - - - `prepare_gaussian_grids`: Process OpenIFS grid files - - `process_fesom_grid`: Handle ocean model grids - - `modify_land_sea_mask`: Core LSM modification - - `generate_oasis_files`: Create OASIS3-MCT files - - `modify_runoff_maps`: Adjust runoff routing - - `generate_plots`: Create visualizations - EOF - - # Examples page - cat > docs/source/examples.md << 'EOF' - # Examples - - ## Basic Usage - - ```python - from ocp_tool import ocp_tool - - # Generate coordinate and area fields - center_lats, center_lons, crn_lats, crn_lons, gridcell_area, lons_list, NN = \ - ocp_tool.generate_coord_area( - res_num=159, - input_path_reduced_grid="input/gaussian_grids_linear_reduced/", - input_path_full_grid="input/gaussian_grids_full/", - truncation_type="linear" - ) - ``` - - ## Workflow Examples - - See the `workflow/` directory for complete Snakemake workflow examples. - EOF - - - name: Build Sphinx documentation - run: | - pixi run -e docs sphinx-build -W -b html docs/source docs/build/html - - - name: Upload documentation artifact - uses: actions/upload-artifact@v4 - with: - name: documentation - path: docs/build/html/ - retention-days: 30 - - deploy-docs: - name: Deploy Documentation - runs-on: ubuntu-latest - needs: build-docs - if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' - - permissions: - contents: read - pages: write - id-token: write - - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - - steps: - - name: Download documentation artifact - uses: actions/download-artifact@v4 - with: - name: documentation - path: ./docs - - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Upload to GitHub Pages - uses: actions/upload-pages-artifact@v3 - with: - path: ./docs - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 - - link-check: - name: Check Documentation Links - runs-on: ubuntu-latest - needs: build-docs - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Download documentation artifact - uses: actions/download-artifact@v4 - with: - name: documentation - path: ./docs - - - name: Check HTML links - uses: lycheeverse/lychee-action@v1 - with: - args: --verbose --no-progress --exclude-all-private docs/**/*.html - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From dfd27e406acf5c67cb6aad8c31a9a0897ae62d45 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:33:00 +0100 Subject: [PATCH 06/18] Add Sphinx documentation and simplified docs workflow --- .github/workflows/docs.yml | 45 +++++++++++++++++++++++++++++ docs/source/_static/.gitkeep | 0 docs/source/_templates/.gitkeep | 0 docs/source/api.rst | 50 +++++++++++++++++++++++++++++++++ docs/source/conf.py | 25 +++++++++++++++++ docs/source/index.rst | 22 +++++++++++++++ docs/source/installation.rst | 28 ++++++++++++++++++ docs/source/usage.rst | 47 +++++++++++++++++++++++++++++++ 8 files changed, 217 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/source/_static/.gitkeep create mode 100644 docs/source/_templates/.gitkeep create mode 100644 docs/source/api.rst create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst create mode 100644 docs/source/installation.rst create mode 100644 docs/source/usage.rst diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..f8aac4c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,45 @@ +name: Documentation + +on: + push: + branches: [ master, main ] + paths: + - 'docs/**' + - 'ocp_tool/**' + pull_request: + branches: [ master, main ] + paths: + - 'docs/**' + - 'ocp_tool/**' + workflow_dispatch: + +jobs: + build-docs: + name: Build Documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install sphinx alabaster + pip install -e . + + - name: Build documentation + run: | + cd docs + sphinx-build -W -b html source build/html + + - name: Upload documentation artifact + uses: actions/upload-artifact@v4 + with: + name: documentation + path: docs/build/html/ + retention-days: 30 diff --git a/docs/source/_static/.gitkeep b/docs/source/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/_templates/.gitkeep b/docs/source/_templates/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/source/api.rst b/docs/source/api.rst new file mode 100644 index 0000000..e67b0f0 --- /dev/null +++ b/docs/source/api.rst @@ -0,0 +1,50 @@ +API Reference +============= + +Configuration +------------- + +.. automodule:: ocp_tool.config + :members: + :undoc-members: + :show-inheritance: + +Grid Processing +--------------- + +.. automodule:: ocp_tool.gaussian_grids + :members: + :undoc-members: + :show-inheritance: + +Land-Sea Mask +------------- + +.. automodule:: ocp_tool.lsm + :members: + :undoc-members: + :show-inheritance: + +OASIS Writer +------------ + +.. automodule:: ocp_tool.oasis_writer + :members: + :undoc-members: + :show-inheritance: + +Runoff Processing +----------------- + +.. automodule:: ocp_tool.runoff + :members: + :undoc-members: + :show-inheritance: + +Plotting +-------- + +.. automodule:: ocp_tool.plotting + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..881aa1d --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,25 @@ +"""Sphinx configuration for OCP-Tool documentation.""" + +import os +import sys +sys.path.insert(0, os.path.abspath('../..')) + +project = 'OCP-Tool' +copyright = '2024, AWI Climate Dynamics' +author = 'Jan Streffing' + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'sphinx.ext.napoleon', +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + +html_theme = 'alabaster' +html_static_path = ['_static'] + +# Napoleon settings for Google/NumPy style docstrings +napoleon_google_docstring = True +napoleon_numpy_docstring = True diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..47f0a32 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,22 @@ +OCP-Tool Documentation +====================== + +OpenIFS Coupling Preparation Tool for climate model coupling. + +OCP-Tool generates OASIS3-MCT input files, modifies the OpenIFS land-sea mask, +and adjusts runoff maps to fit a given FESOM2 mesh or NEMO grid. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + installation + usage + api + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/docs/source/installation.rst b/docs/source/installation.rst new file mode 100644 index 0000000..48c894b --- /dev/null +++ b/docs/source/installation.rst @@ -0,0 +1,28 @@ +Installation +============ + +Requirements +------------ + +- Python 3.9+ +- NetCDF libraries +- ECCODES for GRIB processing +- CDO (Climate Data Operators) +- NCO (NetCDF Operators) + +Quick Install +------------- + +Using conda/mamba:: + + git clone https://github.com/JanStreffing/ocp-tool.git + cd ocp-tool + conda env create -f environment.yaml + conda activate ocp-tool + pip install -e . + +Using pixi:: + + git clone https://github.com/JanStreffing/ocp-tool.git + cd ocp-tool + pixi install diff --git a/docs/source/usage.rst b/docs/source/usage.rst new file mode 100644 index 0000000..4c58d73 --- /dev/null +++ b/docs/source/usage.rst @@ -0,0 +1,47 @@ +Usage +===== + +Configuration +------------- + +OCP-Tool uses YAML configuration files. Example configs are in the ``configs/`` directory: + +- ``TCO95_CORE2.yaml`` - TCO95 atmosphere with CORE2 ocean mesh +- ``TCO319_CORE3.yaml`` - TCO319 atmosphere with CORE3 ocean mesh (with ice cavities) + +Running the Tool +---------------- + +Basic usage:: + + python run_ocp_tool.py configs/TCO95_CORE2.yaml + +Configuration Options +--------------------- + +Atmosphere settings:: + + atmosphere: + resolution_list: [95] # TCO95 + truncation_type: "cubic-octahedral" + experiment_name: "ab45" # ICMGG file prefix + +Ocean settings:: + + ocean: + grid_name: "CORE2" + has_ice_cavities: false + mesh_file: "/path/to/mesh.nc" + +Output Structure +---------------- + +Output is organized by grid combination:: + + output/ + └── TCO95_CORE2/ + ├── lpj-guess/ + ├── oasis_mct3_input/ + ├── openifs_input_modified/ + ├── plots/ + └── runoff_map_modified/ From 01d5a1f74b4e423dcf23caf995c221ab29d6f054 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:37:36 +0100 Subject: [PATCH 07/18] Simplify CI: replace pixi with plain pip --- .github/workflows/ci.yml | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2389aae..8eda55d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,22 +19,23 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Pixi - uses: prefix-dev/setup-pixi@v0.8.1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - environments: dev + python-version: ${{ matrix.python-version }} - name: Check Python scripts syntax run: | - pixi run -e dev python -m py_compile run_ocp_tool.py - pixi run -e dev python -m py_compile ocp_tool/*.py + python -m py_compile run_ocp_tool.py + python -m py_compile ocp_tool/*.py echo "✓ All Python scripts have valid syntax" + - name: Install package + run: pip install -e . + - name: Test import of ocp_tool modules run: | - pixi run -e dev python -c " - import sys - sys.path.insert(0, '.') + python -c " from ocp_tool.config import load_config from ocp_tool.gaussian_grids import generate_gaussian_grid from ocp_tool.lsm import process_land_sea_mask @@ -49,27 +50,28 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Pixi - uses: prefix-dev/setup-pixi@v0.8.1 + - name: Set up Python + uses: actions/setup-python@v5 with: - environments: dev + python-version: '3.11' + + - name: Install dev tools + run: pip install flake8 black isort - name: Run flake8 run: | # Stop the build if there are Python syntax errors or undefined names - pixi run -e dev flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist # Exit-zero treats all errors as warnings - pixi run -e dev flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist + flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist - name: Check code formatting with black run: | - pixi run -e dev black --check --diff --exclude=.git . || echo "::warning::Code formatting issues found. Run 'pixi run format' to fix." + black --check --diff --exclude=.git . || echo "::warning::Code formatting issues found. Run 'black .' to fix." - name: Check import sorting with isort run: | - pixi run -e dev isort --check-only --diff . || echo "::warning::Import sorting issues found. Run 'pixi run sort-imports' to fix." - - # Notebook tests removed - notebooks no longer in repo + isort --check-only --diff . || echo "::warning::Import sorting issues found. Run 'isort .' to fix." documentation: name: Documentation Check From e4730c0251cf116c61117c28564f4353b8108ec5 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:42:05 +0100 Subject: [PATCH 08/18] Replace basemap with cartopy in plotting module --- .github/workflows/ci.yml | 4 +-- .github/workflows/docs.yml | 4 +-- ocp_tool/plotting.py | 54 +++++++++++++++++--------------------- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8eda55d..114c308 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,8 @@ jobs: python -m py_compile ocp_tool/*.py echo "✓ All Python scripts have valid syntax" - - name: Install package - run: pip install -e . + - name: Install dependencies + run: pip install numpy scipy xarray netCDF4 pyyaml - name: Test import of ocp_tool modules run: | diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f8aac4c..6497db5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,13 +29,13 @@ jobs: - name: Install dependencies run: | - pip install sphinx alabaster + pip install sphinx alabaster numpy scipy xarray netCDF4 pyyaml matplotlib cartopy pip install -e . - name: Build documentation run: | cd docs - sphinx-build -W -b html source build/html + sphinx-build -b html source build/html - name: Upload documentation artifact uses: actions/upload-artifact@v4 diff --git a/ocp_tool/plotting.py b/ocp_tool/plotting.py index f936450..846533a 100644 --- a/ocp_tool/plotting.py +++ b/ocp_tool/plotting.py @@ -8,7 +8,8 @@ import numpy as np import matplotlib.pyplot as plt -from mpl_toolkits.basemap import Basemap +import cartopy.crs as ccrs +import cartopy.feature as cfeature from .config import OCPConfig from .gaussian_grids import GaussianGrid @@ -31,7 +32,7 @@ def plot_land_sea_mask( resolution: Truncation number """ fig = plt.figure(figsize=(24, 14)) - ax = fig.add_subplot(111) + ax = fig.add_subplot(111, projection=ccrs.PlateCarree()) # Extract wet and dry points lsm_atm = lsm_data.lsm_binary_atm @@ -42,8 +43,11 @@ def plot_land_sea_mask( xpts_land = grid.center_lons[np.round(lsm_land[:, :]) < 1] ypts_land = grid.center_lats[np.round(lsm_land[:, :]) < 1] - ax.scatter(xpts_land, ypts_land, s=100/resolution, color='red', marker='.', label='New dry points') - ax.scatter(xpts_atm, ypts_atm, s=200/resolution, marker='.', label='Wet points') + ax.scatter(xpts_land, ypts_land, s=100/resolution, color='red', marker='.', + label='New dry points', transform=ccrs.PlateCarree()) + ax.scatter(xpts_atm, ypts_atm, s=200/resolution, marker='.', + label='Wet points', transform=ccrs.PlateCarree()) + ax.add_feature(cfeature.COASTLINE) ax.legend(loc="lower right") output_file = config.output_paths.plots / f'land_points_T{resolution}.png' @@ -81,45 +85,35 @@ def plot_runoff_maps( cmap = plt.cm.flag - # Amazon region - m = Basemap( - llcrnrlon=-60., llcrnrlat=-10, - urcrnrlon=-30., urcrnrlat=20., - resolution='l', area_thresh=1000., projection='cyl' - ) - xi, yi = m(lon, lat) - + # Amazon region - arrival fig = plt.figure(figsize=(12, 8)) - m.pcolor(xi, yi, arrival_cat, cmap=cmap) - m.drawcoastlines() - m.drawparallels(np.arange(-90., 120., 45.)) - m.drawmeridians(np.arange(0., 360., 90.)) + ax = fig.add_subplot(111, projection=ccrs.PlateCarree()) + ax.set_extent([-60, -30, -10, 20], crs=ccrs.PlateCarree()) + ax.pcolormesh(lon, lat, arrival_cat, cmap=cmap, transform=ccrs.PlateCarree()) + ax.add_feature(cfeature.COASTLINE) + ax.gridlines(draw_labels=True) output_file = config.output_paths.plots / 'runoff_amazon_arrival.png' fig.savefig(str(output_file), format='png') plt.close(fig) # Ob region - drainage - m = Basemap( - llcrnrlon=50., llcrnrlat=40, - urcrnrlon=110., urcrnrlat=80., - resolution='l', area_thresh=1000., projection='cyl' - ) - fig = plt.figure(figsize=(12, 8)) - m.pcolor(xi, yi, drainage_cat, cmap=cmap) - m.drawcoastlines() - m.drawparallels(np.arange(-90., 120., 45.)) - m.drawmeridians(np.arange(0., 360., 90.)) + ax = fig.add_subplot(111, projection=ccrs.PlateCarree()) + ax.set_extent([50, 110, 40, 80], crs=ccrs.PlateCarree()) + ax.pcolormesh(lon, lat, drainage_cat, cmap=cmap, transform=ccrs.PlateCarree()) + ax.add_feature(cfeature.COASTLINE) + ax.gridlines(draw_labels=True) output_file = config.output_paths.plots / 'runoff_ob_drainage.png' fig.savefig(str(output_file), format='png') plt.close(fig) # Ob region - arrival fig = plt.figure(figsize=(12, 8)) - m.pcolor(xi, yi, arrival_cat, cmap=cmap) - m.drawcoastlines() - m.drawparallels(np.arange(-90., 120., 45.)) - m.drawmeridians(np.arange(0., 360., 90.)) + ax = fig.add_subplot(111, projection=ccrs.PlateCarree()) + ax.set_extent([50, 110, 40, 80], crs=ccrs.PlateCarree()) + ax.pcolormesh(lon, lat, arrival_cat, cmap=cmap, transform=ccrs.PlateCarree()) + ax.add_feature(cfeature.COASTLINE) + ax.gridlines(draw_labels=True) output_file = config.output_paths.plots / 'runoff_ob_arrival.png' fig.savefig(str(output_file), format='png') plt.close(fig) From a82542d313029aa2758332b328ce615f09457e8e Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:43:30 +0100 Subject: [PATCH 09/18] Fix CI: only test modules without conda-only deps (gribapi) --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 114c308..ee2b2b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,9 +36,9 @@ jobs: - name: Test import of ocp_tool modules run: | python -c " - from ocp_tool.config import load_config - from ocp_tool.gaussian_grids import generate_gaussian_grid - from ocp_tool.lsm import process_land_sea_mask + # Test modules that don't require conda-only packages (eccodes/gribapi) + from ocp_tool.config import load_config, OCPConfig + from ocp_tool.runoff import modify_runoff_map print('✓ Successfully imported ocp_tool modules') " From 57267dd8985a754b762a9d091e5f71ca3d484054 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:44:24 +0100 Subject: [PATCH 10/18] Simplify CI: remove import test (needs conda), keep syntax + code quality --- .github/workflows/ci.yml | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee2b2b3..2225a12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,18 +29,6 @@ jobs: python -m py_compile run_ocp_tool.py python -m py_compile ocp_tool/*.py echo "✓ All Python scripts have valid syntax" - - - name: Install dependencies - run: pip install numpy scipy xarray netCDF4 pyyaml - - - name: Test import of ocp_tool modules - run: | - python -c " - # Test modules that don't require conda-only packages (eccodes/gribapi) - from ocp_tool.config import load_config, OCPConfig - from ocp_tool.runoff import modify_runoff_map - print('✓ Successfully imported ocp_tool modules') - " code-quality: name: Code Quality Checks From 0970038b5f81339ea9d7de08504055fa2a317516 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 09:48:44 +0100 Subject: [PATCH 11/18] Restore pixi CI for proper testing with conda packages (eccodes, cartopy) --- .github/workflows/ci.yml | 42 ++++++++++++++++++++++------------------ pyproject.toml | 9 ++++----- 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2225a12..f0b0f6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,24 +11,31 @@ jobs: test-python: name: Test Python Code runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11"] steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} + - name: Setup Pixi + uses: prefix-dev/setup-pixi@v0.8.1 - name: Check Python scripts syntax run: | - python -m py_compile run_ocp_tool.py - python -m py_compile ocp_tool/*.py + pixi run python -m py_compile run_ocp_tool.py + pixi run python -m py_compile ocp_tool/*.py echo "✓ All Python scripts have valid syntax" + + - name: Test import of ocp_tool modules + run: | + pixi run python -c " + from ocp_tool.config import load_config, OCPConfig + from ocp_tool.gaussian_grids import generate_gaussian_grid + from ocp_tool.lsm import process_land_sea_mask + from ocp_tool.oasis_writer import write_oasis_grid_files + from ocp_tool.runoff import modify_runoff_map + from ocp_tool.plotting import plot_land_sea_mask + print('✓ Successfully imported all ocp_tool modules') + " code-quality: name: Code Quality Checks @@ -38,28 +45,25 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 + - name: Setup Pixi + uses: prefix-dev/setup-pixi@v0.8.1 with: - python-version: '3.11' - - - name: Install dev tools - run: pip install flake8 black isort + environments: dev - name: Run flake8 run: | # Stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist + pixi run -e dev flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist # Exit-zero treats all errors as warnings - flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist + pixi run -e dev flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist - name: Check code formatting with black run: | - black --check --diff --exclude=.git . || echo "::warning::Code formatting issues found. Run 'black .' to fix." + pixi run -e dev black --check --diff --exclude=.git . || echo "::warning::Code formatting issues found." - name: Check import sorting with isort run: | - isort --check-only --diff . || echo "::warning::Import sorting issues found. Run 'isort .' to fix." + pixi run -e dev isort --check-only --diff . || echo "::warning::Import sorting issues found." documentation: name: Documentation Check diff --git a/pyproject.toml b/pyproject.toml index f5e5d7e..be4c22d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,18 +79,17 @@ channels = ["conda-forge", "bioconda", "eumetsat"] platforms = ["linux-64", "linux-aarch64", "osx-64", "osx-arm64"] [tool.pixi.dependencies] -python = ">=3.8" +python = ">=3.9" numpy = "*" +scipy = "*" netcdf4 = "*" +xarray = "*" matplotlib = "*" -basemap = "*" +cartopy = "*" pandas = "*" pyyaml = "*" python-eccodes = "*" -git = "*" pip = "*" -tqdm = "*" -snakemake-minimal = ">=7.0" [tool.pixi.pypi-dependencies] # Use modernized pyfesom2 branch with Python 3.11+ support From 5cc9b26f9317749a28426cb0c82d63d2dd2e22ec Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 10:08:28 +0100 Subject: [PATCH 12/18] Fix CI: exclude .pixi directory from linting tools --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0b0f6c..1aea861 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,17 +53,17 @@ jobs: - name: Run flake8 run: | # Stop the build if there are Python syntax errors or undefined names - pixi run -e dev flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist + pixi run -e dev flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude=.git,__pycache__,build,dist,.pixi # Exit-zero treats all errors as warnings - pixi run -e dev flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist + pixi run -e dev flake8 . --count --exit-zero --max-complexity=15 --max-line-length=120 --statistics --exclude=.git,__pycache__,build,dist,.pixi - name: Check code formatting with black run: | - pixi run -e dev black --check --diff --exclude=.git . || echo "::warning::Code formatting issues found." + pixi run -e dev black --check --diff --exclude='(\.git|\.pixi)' . || echo "::warning::Code formatting issues found." - name: Check import sorting with isort run: | - pixi run -e dev isort --check-only --diff . || echo "::warning::Import sorting issues found." + pixi run -e dev isort --check-only --diff --skip .pixi . || echo "::warning::Import sorting issues found." documentation: name: Documentation Check From 65c95d710ab671964f69546a793cf6dd0e450ecc Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 10:27:46 +0100 Subject: [PATCH 13/18] Fix Python 3.9 compatibility and update installation docs --- docs/source/installation.rst | 23 +++++++++++++++++++---- ocp_tool/config.py | 4 ++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 48c894b..0863459 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -10,10 +10,22 @@ Requirements - CDO (Climate Data Operators) - NCO (NetCDF Operators) -Quick Install -------------- +Quick Install (Recommended) +--------------------------- -Using conda/mamba:: +Clone from GitHub and install dependencies manually:: + + git clone https://github.com/JanStreffing/ocp-tool.git + cd ocp-tool + +Then install the required Python packages in your existing environment. +This is the most common approach, especially on HPC systems where +you may already have the dependencies available via modules. + +Using conda/mamba +----------------- + +Create a dedicated environment with all dependencies:: git clone https://github.com/JanStreffing/ocp-tool.git cd ocp-tool @@ -21,7 +33,10 @@ Using conda/mamba:: conda activate ocp-tool pip install -e . -Using pixi:: +Using pixi +---------- + +Pixi handles all dependencies automatically:: git clone https://github.com/JanStreffing/ocp-tool.git cd ocp-tool diff --git a/ocp_tool/config.py b/ocp_tool/config.py index 873390d..7e40f76 100644 --- a/ocp_tool/config.py +++ b/ocp_tool/config.py @@ -5,7 +5,7 @@ from dataclasses import dataclass, field from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Union import yaml @@ -98,7 +98,7 @@ def get_icmgg_iniua_file(self) -> Path: return self.output_paths.openifs_modified / f'ICMGG{self.atmosphere.experiment_name}INIUA' -def load_config(config_path: str | Path) -> OCPConfig: +def load_config(config_path: Union[str, Path]) -> OCPConfig: """ Load configuration from YAML file. From c9449e83d24f9eff913eb603a9a4ef1ac3429177 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 10:30:51 +0100 Subject: [PATCH 14/18] Update LSM plotting to use PolyCollection with cell corners --- ocp_tool/plotting.py | 108 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 17 deletions(-) diff --git a/ocp_tool/plotting.py b/ocp_tool/plotting.py index 846533a..a8d83e6 100644 --- a/ocp_tool/plotting.py +++ b/ocp_tool/plotting.py @@ -1,6 +1,7 @@ """ Plotting module for OCP-Tool. Handles visualization of land-sea masks and runoff maps. +Uses polygon-based plotting with cell corners for accurate representation. """ from pathlib import Path @@ -8,6 +9,7 @@ import numpy as np import matplotlib.pyplot as plt +from matplotlib.collections import PolyCollection import cartopy.crs as ccrs import cartopy.feature as cfeature @@ -16,6 +18,38 @@ from .lsm import LSMData +def _build_cell_vertices(grid: GaussianGrid) -> np.ndarray: + """ + Build cell vertices array from grid corner coordinates. + + Args: + grid: GaussianGrid with corner_lons and corner_lats + + Returns: + Array of shape (n_cells, 4, 2) with [lon, lat] for each corner + """ + # corner_lons/lats have shape (4, 1, n_points), squeeze and transpose + clo = grid.corner_lons.squeeze().T # (n_points, 4) + cla = grid.corner_lats.squeeze().T # (n_points, 4) + cell_verts = np.stack([clo, cla], axis=-1) # (n_points, 4, 2) + return cell_verts + + +def _get_valid_cell_mask(grid: GaussianGrid) -> np.ndarray: + """ + Create mask for cells that don't wrap around the date line. + + Args: + grid: GaussianGrid with corner coordinates + + Returns: + Boolean mask array + """ + clo = grid.corner_lons.squeeze().T # (n_points, 4) + lon_range = np.max(clo, axis=1) - np.min(clo, axis=1) + return lon_range < 180 + + def plot_land_sea_mask( config: OCPConfig, grid: GaussianGrid, @@ -23,35 +57,75 @@ def plot_land_sea_mask( resolution: int ) -> None: """ - Plot the final land-sea mask showing wet and dry points. + Plot the final land-sea mask using polygon cells. Args: config: OCP configuration - grid: Gaussian grid data + grid: Gaussian grid data with corners lsm_data: Land-sea mask data resolution: Truncation number """ - fig = plt.figure(figsize=(24, 14)) + fig = plt.figure(figsize=(16, 10)) ax = fig.add_subplot(111, projection=ccrs.PlateCarree()) - # Extract wet and dry points - lsm_atm = lsm_data.lsm_binary_atm - lsm_land = lsm_data.lsm_binary_land + # Build cell vertices from corners + cell_verts = _build_cell_vertices(grid) + valid_mask = _get_valid_cell_mask(grid) - xpts_atm = grid.center_lons[np.round(lsm_atm[:, :]) < 1] - ypts_atm = grid.center_lats[np.round(lsm_atm[:, :]) < 1] - xpts_land = grid.center_lons[np.round(lsm_land[:, :]) < 1] - ypts_land = grid.center_lats[np.round(lsm_land[:, :]) < 1] + # Get LSM values (flatten to 1D) + lsm_atm = lsm_data.lsm_binary_atm.flatten() + lsm_land = lsm_data.lsm_binary_land.flatten() - ax.scatter(xpts_land, ypts_land, s=100/resolution, color='red', marker='.', - label='New dry points', transform=ccrs.PlateCarree()) - ax.scatter(xpts_atm, ypts_atm, s=200/resolution, marker='.', - label='Wet points', transform=ccrs.PlateCarree()) - ax.add_feature(cfeature.COASTLINE) - ax.legend(loc="lower right") + # Create color array: 0=wet (blue), 1=land (tan), new dry points (red) + # wet points: lsm_atm < 1 + # new dry points: lsm_land < 1 but lsm_atm >= 1 (points that became dry) + colors = np.zeros(len(lsm_atm)) + colors[lsm_atm >= 0.5] = 1 # Land + colors[lsm_atm < 0.5] = 0 # Wet + + # Identify new dry points (were wet in atm, now dry in land) + new_dry = (np.round(lsm_land) < 1) & (np.round(lsm_atm) >= 0.5) + colors[new_dry] = 2 # New dry points + + # Apply valid mask + valid_verts = cell_verts[valid_mask] + valid_colors = colors[valid_mask] + + # Create custom colormap: blue=wet, tan=land, red=new dry + from matplotlib.colors import ListedColormap + cmap = ListedColormap(['#4169E1', '#D2B48C', '#FF4444']) # Blue, Tan, Red + + # Create PolyCollection + collection = PolyCollection( + valid_verts, + array=valid_colors, + cmap=cmap, + edgecolors='none', + linewidths=0, + transform=ccrs.PlateCarree(), + ) + collection.set_clim(0, 2) + ax.add_collection(collection) + + # Set global extent + ax.set_global() + + # Add coastlines for reference + ax.coastlines(linewidth=0.5, color='black', zorder=5) + + # Legend + from matplotlib.patches import Patch + legend_elements = [ + Patch(facecolor='#4169E1', label='Wet points'), + Patch(facecolor='#D2B48C', label='Land'), + Patch(facecolor='#FF4444', label='New dry points'), + ] + ax.legend(handles=legend_elements, loc='lower right') + + ax.set_title(f'Land-Sea Mask T{resolution}', fontsize=14) output_file = config.output_paths.plots / f'land_points_T{resolution}.png' - fig.savefig(str(output_file), format='png', dpi=600) + fig.savefig(str(output_file), format='png', dpi=300, bbox_inches='tight') plt.close(fig) print(f"Saved LSM plot to {output_file}") From 0a4c317d0f44b6dc89ee2526bf7691d5f7a24472 Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 11:14:57 +0100 Subject: [PATCH 15/18] update plot routine to use polygons --- ocp_tool/gaussian_grids.py | 297 ++++++++++++++++++++++++++++++++----- ocp_tool/lsm.py | 1 + run_ocp_tool.py | 4 +- 3 files changed, 262 insertions(+), 40 deletions(-) diff --git a/ocp_tool/gaussian_grids.py b/ocp_tool/gaussian_grids.py index 4439042..f391c08 100644 --- a/ocp_tool/gaussian_grids.py +++ b/ocp_tool/gaussian_grids.py @@ -11,6 +11,8 @@ import numpy as np from netCDF4 import Dataset +from collections import defaultdict +from scipy.spatial import ConvexHull from .config import OCPConfig, EARTH_RADIUS_M @@ -347,61 +349,280 @@ def generate_gaussian_grid( ) -def read_fesom_grid( +def read_fesom_grid_polygon( config: OCPConfig, + grid: 'GaussianGrid', verbose: bool = False ) -> np.ndarray: """ - Read FESOM ocean grid and generate OpenIFS-compatible land-sea mask. + Read FESOM ocean grid and generate OpenIFS-compatible land-sea mask using polygon method. - Uses prep_fesom.sh script to process the mesh file. + This method uses exact triangulation to determine if each OpenIFS grid point + falls inside the FESOM ocean mesh. It scales well for high-resolution grids + as it doesn't require an intermediate regular grid. + Algorithm: + 1. Build ocean boundary from coastal edges + calving front edges + 2. Create convex hulls for small disconnected cavity components + 3. Point is LAND if: outside ocean polygon OR inside cavity hull + + Args: + config: OCP configuration + grid: Gaussian grid data with center coordinates + verbose: Print debug info + Returns: - Array of land-sea mask values on the OpenIFS grid + Array of land-sea mask values (1=land, 0=ocean) on the OpenIFS grid """ - print(f"\n {'='*50} \n") - print(" Using cdo and nco to interpolate from ocean to OpenIFS land sea mask") + from shapely.geometry import Point, Polygon + from shapely.ops import unary_union + from shapely.prepared import prep + + print(f"\n {'='*50}") + print(" Using POLYGON method for land-sea mask (no intermediate grid)") + print(f" {'='*50}\n") - fesom_mesh_dir = config.input_paths.fesom_mesh - ocean_grid = config.ocean.grid_name mesh_file = config.ocean.mesh_file - interp_res = config.ocean.intermediate_resolution - cavity = str(config.ocean.has_ice_cavities) - exp_name = config.atmosphere.experiment_name + has_cavities = config.ocean.has_ice_cavities - # Save current directory and change to fesom_mesh directory - original_dir = os.getcwd() - os.chdir(fesom_mesh_dir) + print(f" Loading FESOM mesh: {mesh_file}") + mesh = Dataset(str(mesh_file), 'r') + lon = mesh.variables['lon'][:] + lat = mesh.variables['lat'][:] + triag = mesh.variables['triag_nodes'][:] - 1 # Convert to 0-indexed - try: - print(f" Does FESOM grid path exist? {mesh_file.exists()}") - print(f" Is the overwrite active? {config.ocean.force_overwrite_griddes}") + if has_cavities and 'cav_nod_mask' in mesh.variables: + cav_mask = mesh.variables['cav_nod_mask'][:] + print(f" Cavity nodes: {int(np.sum(cav_mask == 1))}") + else: + cav_mask = np.zeros(len(lon)) + print(" No cavity mask found or cavities disabled") + + mesh.close() + + # Build edge information + print(" Building edge information...") + edge_triangles = defaultdict(int) + for tri in triag: + for e in [(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])]: + edge_triangles[tuple(sorted(e))] += 1 + + # Classify edges + cavity_ocean_edges = [] + coastal_edges = [] + + for (n0, n1), count in edge_triangles.items(): + c0, c1 = cav_mask[n0], cav_mask[n1] + if count == 2 and c0 != c1: + # Internal edge between cavity and ocean + cavity_ocean_edges.append((n0, n1)) + elif count == 1 and c0 == 0 and c1 == 0: + # Boundary edge with no cavity nodes (coastline) + coastal_edges.append((n0, n1)) + + print(f" Calving front edges: {len(cavity_ocean_edges)}") + print(f" Coastal edges: {len(coastal_edges)}") + + # Build ocean boundary and trace polygons + new_boundary = coastal_edges + cavity_ocean_edges + adj = defaultdict(set) + for n0, n1 in new_boundary: + adj[n0].add(n1) + adj[n1].add(n0) + + visited = set() + polygon_rings = [] + + def trace_ring(start): + ring = [start] + current, prev = start, None + while True: + neighbors = adj[current] - {prev} if prev else adj[current] + if not neighbors: + break + next_n = min(neighbors) + edge = tuple(sorted([current, next_n])) + if edge in visited: + neighbors = neighbors - {next_n} + if not neighbors: + break + next_n = min(neighbors) + edge = tuple(sorted([current, next_n])) + if edge in visited: + break + visited.add(edge) + if next_n == start: + break + ring.append(next_n) + prev, current = current, next_n + if len(ring) > 100000: + break + return ring + + print(" Tracing polygon boundaries...") + for node in sorted(adj.keys()): + if any(tuple(sorted([node, n])) not in visited for n in adj[node]): + ring = trace_ring(node) + if len(ring) >= 3: + polygon_rings.append(ring) + + print(f" Found {len(polygon_rings)} polygon rings") + + # Create shapely polygons + shapely_polys = [] + for ring in polygon_rings: + coords = [(lon[n], lat[n]) for n in ring] + lons_ring = [c[0] for c in coords] + if max(lons_ring) - min(lons_ring) > 180: + continue # Skip dateline-crossing polygons + try: + p = Polygon(coords) + if p.is_valid and p.area > 0.0001: + shapely_polys.append(p) + except: + pass + + print(f" Valid shapely polygons: {len(shapely_polys)}") + + ocean_poly = unary_union(shapely_polys) + ocean_prep = prep(ocean_poly) + + # Build cavity hulls for small disconnected components + cavity_prep = None + if has_cavities and np.sum(cav_mask == 1) > 0: + print(" Building cavity hulls for small components...") - if mesh_file.exists() and not config.ocean.force_overwrite_griddes: - print(f" Using existing grid description file '{mesh_file}'") - cmd = f'./prep_fesom.sh {mesh_file} {ocean_grid} {interp_res} ../openifs_input_default/ICMGG{exp_name}INIT {cavity}' - else: - # Would need pyfesom2 to create mesh - for now, require existing file - raise FileNotFoundError(f"Mesh file not found and force_overwrite_griddes=False: {mesh_file}") + # Find connected components of cavity nodes + node_adj = defaultdict(set) + for tri in triag: + for i in range(3): + for j in range(i+1, 3): + node_adj[tri[i]].add(tri[j]) + node_adj[tri[j]].add(tri[i]) - print(f"\n {'='*50} \n") - print(" Using the following command to generate OpenIFS lsm based on FESOM mesh description file:") - print(cmd) - print(f" Reading ocean based land sea mask: {ocean_grid}") + cavity_nodes = set(np.where(cav_mask == 1)[0]) + visited_nodes = set() + components = [] - os.system(cmd) + def bfs(start): + comp = set() + queue = [start] + while queue: + node = queue.pop(0) + if node in visited_nodes: + continue + visited_nodes.add(node) + comp.add(node) + for neighbor in node_adj[node]: + if neighbor in cavity_nodes and neighbor not in visited_nodes: + queue.append(neighbor) + return comp - # Read the generated file - oifs_file = fesom_mesh_dir / f'{ocean_grid}_oifs.nc' - mesh = Dataset(str(oifs_file), 'r') + for node in cavity_nodes: + if node not in visited_nodes: + components.append(bfs(node)) - if verbose: - print(mesh.variables.keys()) + print(f" Cavity components: {len(components)}") + + # Create convex hulls for small components + cavity_hulls = [] + for comp in components: + if len(comp) < 50 and len(comp) >= 3: + nodes = list(comp) + coords = np.column_stack([lon[nodes], lat[nodes]]) + try: + hull = ConvexHull(coords) + p = Polygon(coords[hull.vertices]) + if p.is_valid: + cavity_hulls.append(p) + except: + pass + + if cavity_hulls: + cavity_poly = unary_union(cavity_hulls) + cavity_prep = prep(cavity_poly) + print(f" Small cavity hulls: {len(cavity_hulls)}") + + # Get OpenIFS grid coordinates + oifs_lons = np.array(grid.lons_list) + oifs_lats = grid.center_lats.flatten() + + # Normalize longitudes to -180 to 180 + oifs_lons_std = np.where(oifs_lons > 180, oifs_lons - 360, oifs_lons) + + print(f" Testing {len(oifs_lons)} OpenIFS grid points...") + + # Use matplotlib triangulation for EXACT point location (no search radius) + from matplotlib.tri import Triangulation + + print(" Building exact triangulation lookup...") + + # Check if triangle has ANY cavity node + tri_has_cavity = np.any(cav_mask[triag] == 1, axis=1) + + # Split triangles by dateline + tri_lons_all = lon[triag] + lon_spans = tri_lons_all.max(axis=1) - tri_lons_all.min(axis=1) + valid_std = lon_spans <= 180 # Non-dateline triangles + valid_dl = lon_spans > 180 # Dateline-crossing triangles + + fesom_lsm = np.ones(len(oifs_lons)) # Default: land (1) + + # Pass 1: Standard coordinates for non-dateline triangles + print(f" Pass 1: {np.sum(valid_std)} triangles (standard coords)...") + triag_std = triag[valid_std] + cavity_std = tri_has_cavity[valid_std] + + try: + tri_std = Triangulation(lon, lat, triangles=triag_std) + finder_std = tri_std.get_trifinder() + tri_indices = finder_std(oifs_lons_std, oifs_lats) + + # Vectorized assignment + found = tri_indices >= 0 + is_cavity = np.zeros(len(oifs_lons), dtype=bool) + is_cavity[found] = cavity_std[tri_indices[found]] + fesom_lsm[found & ~is_cavity] = 0 # Ocean + fesom_lsm[found & is_cavity] = 1 # Cavity -> land + + print(f" Found {np.sum(found)} points in standard triangles") + except Exception as e: + print(f" Warning: Standard triangulation failed: {e}") + + # Pass 2: Shifted coordinates for dateline triangles + n_dl = np.sum(valid_dl) + if n_dl > 0: + print(f" Pass 2: {n_dl} triangles (dateline, shifted coords)...") - fesom_lsm = mesh.variables['cell_area'][:] - mesh.close() + # Shift longitudes to 0-360 for dateline handling + lon_shifted = np.where(lon < 0, lon + 360, lon) + oifs_lons_shifted = np.where(oifs_lons_std < 0, oifs_lons_std + 360, oifs_lons_std) - return fesom_lsm + triag_dl = triag[valid_dl] + cavity_dl = tri_has_cavity[valid_dl] - finally: - os.chdir(original_dir) + try: + tri_dl = Triangulation(lon_shifted, lat, triangles=triag_dl) + finder_dl = tri_dl.get_trifinder() + + # Only check points not yet classified as ocean + unclassified = fesom_lsm == 1 + unclass_indices = np.where(unclassified)[0] + tri_indices_dl = finder_dl(oifs_lons_shifted[unclassified], oifs_lats[unclassified]) + + # Vectorized assignment + found_dl = tri_indices_dl >= 0 + found_indices = unclass_indices[found_dl] + is_cavity_dl = cavity_dl[tri_indices_dl[found_dl]] + fesom_lsm[found_indices[~is_cavity_dl]] = 0 # Ocean + fesom_lsm[found_indices[is_cavity_dl]] = 1 # Cavity -> land + + print(f" Found {np.sum(found_dl)} points in dateline triangles") + except Exception as e: + print(f" Warning: Dateline triangulation failed: {e}") + + land_count = int(np.sum(fesom_lsm == 1)) + ocean_count = int(np.sum(fesom_lsm == 0)) + print(f" Result: Land={land_count}, Ocean={ocean_count}") + + return fesom_lsm diff --git a/ocp_tool/lsm.py b/ocp_tool/lsm.py index c512fef..752f51f 100644 --- a/ocp_tool/lsm.py +++ b/ocp_tool/lsm.py @@ -157,6 +157,7 @@ def modify_lsm( if ocean_grid_name != 'AMIP': # Automatic lake removal based on ocean mask + # Polygon method: ocean_lsm = 1 means land, 0 means ocean n_points = len(gribfield_mod[slt_id]) for i in range(n_points - 1): diff --git a/run_ocp_tool.py b/run_ocp_tool.py index 1c556c7..42c29bf 100644 --- a/run_ocp_tool.py +++ b/run_ocp_tool.py @@ -15,7 +15,7 @@ from pathlib import Path from ocp_tool.config import load_config, OCPConfig -from ocp_tool.gaussian_grids import generate_gaussian_grid, read_fesom_grid +from ocp_tool.gaussian_grids import generate_gaussian_grid, read_fesom_grid_polygon from ocp_tool.lsm import process_land_sea_mask, create_slt_output_for_lpjg from ocp_tool.oasis_writer import write_oasis_grid_files, interpolate_vegin_data from ocp_tool.runoff import modify_runoff_map, modify_runoff_lsm @@ -54,7 +54,7 @@ def run_ocp_tool(config: OCPConfig) -> None: # Step 2: Read ocean grid (if not AMIP) if config.ocean.grid_name != 'AMIP': print("\nStep 2: Reading FESOM ocean grid...") - ocean_lsm = read_fesom_grid(config, verbose=config.options.verbose) + ocean_lsm = read_fesom_grid_polygon(config, grid, verbose=config.options.verbose) else: print("\nStep 2: Skipped reading FESOM mesh (AMIP mode)") ocean_lsm = [] From fad6dde0ef0d8254d2b368c52d71ce05209ad61c Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 12:55:28 +0100 Subject: [PATCH 16/18] Use polygon method for LSM, add pyfesom2 mesh generation - Switch from CDO-based to polygon method for land-sea mask (from master) - Add pyfesom2 mesh.nc generation when file missing or force_overwrite=true - Fix masked array handling for cav_nod_mask in CORE3 - Update run_ocp_tool.py to use read_fesom_grid_polygon --- ocp_tool/gaussian_grids.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/ocp_tool/gaussian_grids.py b/ocp_tool/gaussian_grids.py index f391c08..52a4e1a 100644 --- a/ocp_tool/gaussian_grids.py +++ b/ocp_tool/gaussian_grids.py @@ -361,6 +361,9 @@ def read_fesom_grid_polygon( falls inside the FESOM ocean mesh. It scales well for high-resolution grids as it doesn't require an intermediate regular grid. + If mesh_file doesn't exist or force_overwrite_griddes is True, attempts to + generate mesh.nc from FESOM ASCII files using pyfesom2. + Algorithm: 1. Build ocean boundary from coastal edges + calving front edges 2. Create convex hulls for small disconnected cavity components @@ -385,6 +388,25 @@ def read_fesom_grid_polygon( mesh_file = config.ocean.mesh_file has_cavities = config.ocean.has_ice_cavities + # Generate mesh.nc from ASCII files if needed + if not mesh_file.exists() or config.ocean.force_overwrite_griddes: + try: + import pyfesom2 as pf + griddir = str(mesh_file.parent) + if mesh_file.exists(): + print(f" mesh.nc exists but force_overwrite_griddes=True, regenerating via pyfesom2") + else: + print(f" mesh.nc not found, generating from ASCII files via pyfesom2") + print(f" Reading FESOM ASCII grid from: {griddir}") + fesom_grid = pf.read_fesom_ascii_grid(griddir=griddir, cavity=has_cavities) + pf.write_mesh_to_netcdf(fesom_grid, ofile=str(mesh_file), overwrite=True, cavity=has_cavities) + print(f" Created mesh.nc: {mesh_file}") + except ImportError: + raise ImportError("pyfesom2 is required to generate mesh.nc from ASCII files. " + "Install with: pip install git+https://github.com/FESOM/pyfesom2.git") + except Exception as e: + raise RuntimeError(f"Failed to generate mesh.nc via pyfesom2: {e}") + print(f" Loading FESOM mesh: {mesh_file}") mesh = Dataset(str(mesh_file), 'r') lon = mesh.variables['lon'][:] @@ -392,8 +414,9 @@ def read_fesom_grid_polygon( triag = mesh.variables['triag_nodes'][:] - 1 # Convert to 0-indexed if has_cavities and 'cav_nod_mask' in mesh.variables: - cav_mask = mesh.variables['cav_nod_mask'][:] - print(f" Cavity nodes: {int(np.sum(cav_mask == 1))}") + cav_mask = np.ma.filled(mesh.variables['cav_nod_mask'][:], 0) + n_cavity = int(np.sum(cav_mask == 1)) + print(f" Cavity nodes: {n_cavity}") else: cav_mask = np.zeros(len(lon)) print(" No cavity mask found or cavities disabled") From 1de134b66dd551ec3a635a2f36b71a4c01960d2a Mon Sep 17 00:00:00 2001 From: Jan Streffing Date: Tue, 16 Dec 2025 14:43:59 +0100 Subject: [PATCH 17/18] Fix pyfesom2 install reference to use main branch The cavity support and bug fix are now in main, no need for the old feat/ascii_to_netcdf_with_cavity branch. --- ocp_tool/config.py | 4 ++++ ocp_tool/gaussian_grids.py | 2 +- ocp_tool/grids.py | 0 ocp_tool/oasis_writer.py | 14 +++++++++++--- 4 files changed, 16 insertions(+), 4 deletions(-) create mode 100644 ocp_tool/grids.py diff --git a/ocp_tool/config.py b/ocp_tool/config.py index a0e9f0b..08446a1 100644 --- a/ocp_tool/config.py +++ b/ocp_tool/config.py @@ -23,6 +23,8 @@ class OceanConfig: grid_name: str has_ice_cavities: bool mesh_file: Path + intermediate_resolution: str = "r360x181" + force_overwrite_griddes: bool = False @dataclass @@ -170,6 +172,8 @@ def resolve_path(path_str: str) -> Path: grid_name=raw['ocean']['grid_name'], has_ice_cavities=raw['ocean']['has_ice_cavities'], mesh_file=Path(raw['ocean']['mesh_file']), + intermediate_resolution=raw['ocean'].get('intermediate_resolution', 'r360x181'), + force_overwrite_griddes=raw['ocean'].get('force_overwrite_griddes', False), ), runoff=RunoffConfig( manual_basin_removal=raw['runoff']['manual_basin_removal'], diff --git a/ocp_tool/gaussian_grids.py b/ocp_tool/gaussian_grids.py index 52a4e1a..2785466 100644 --- a/ocp_tool/gaussian_grids.py +++ b/ocp_tool/gaussian_grids.py @@ -402,7 +402,7 @@ def read_fesom_grid_polygon( pf.write_mesh_to_netcdf(fesom_grid, ofile=str(mesh_file), overwrite=True, cavity=has_cavities) print(f" Created mesh.nc: {mesh_file}") except ImportError: - raise ImportError("pyfesom2 is required to generate mesh.nc from ASCII files. " + raise ImportError("pyfesom2 is required for mesh generation. " "Install with: pip install git+https://github.com/FESOM/pyfesom2.git") except Exception as e: raise RuntimeError(f"Failed to generate mesh.nc via pyfesom2: {e}") diff --git a/ocp_tool/grids.py b/ocp_tool/grids.py new file mode 100644 index 0000000..e69de29 diff --git a/ocp_tool/oasis_writer.py b/ocp_tool/oasis_writer.py index 892b455..53410de 100644 --- a/ocp_tool/oasis_writer.py +++ b/ocp_tool/oasis_writer.py @@ -96,8 +96,15 @@ def _write_single_oasis_file( lonname = f'{grids_name}.lon' latname = f'{grids_name}.lat' - nc.createDimension(xname, grid.center_lons.shape[1]) - nc.createDimension(yname, 1) + # Only create dimensions if they don't exist + if xname not in nc.dimensions: + nc.createDimension(xname, grid.center_lons.shape[1]) + if yname not in nc.dimensions: + nc.createDimension(yname, 1) + + # Skip if variables already exist (avoid duplicates) + if lonname in nc.variables: + continue id_lon = nc.createVariable(lonname, 'float64', (yname, xname)) id_lat = nc.createVariable(latname, 'float64', (yname, xname)) @@ -111,7 +118,8 @@ def _write_single_oasis_file( crnname = f'crn_{grids_name}' cloname = f'{grids_name}.clo' claname = f'{grids_name}.cla' - nc.createDimension(crnname, 4) + if crnname not in nc.dimensions: + nc.createDimension(crnname, 4) id_clo = nc.createVariable(cloname, 'float64', (crnname, yname, xname)) id_cla = nc.createVariable(claname, 'float64', (crnname, yname, xname)) From 606a1ca2b83afb7d765e5fe9b8c1c8225c32952f Mon Sep 17 00:00:00 2001 From: FinnHeu Date: Wed, 17 Dec 2025 22:48:58 +0100 Subject: [PATCH 18/18] Added a function to create the output directories for the resulting files for each config --- ocp_tool/create_outputdirs.py | 13 +++++++++++++ run_ocp_tool.py | 9 ++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 ocp_tool/create_outputdirs.py diff --git a/ocp_tool/create_outputdirs.py b/ocp_tool/create_outputdirs.py new file mode 100644 index 0000000..0427a40 --- /dev/null +++ b/ocp_tool/create_outputdirs.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +from os import makedirs + +def create_outputdirs(config, resolution): + """Create necessary output directories based on configuration and resolution.""" + output_dir = f"./output/TCO{resolution}_{config.ocean.grid_name}/" + makedirs(f"{output_dir}lpj-guess", exist_ok=True) + makedirs(f"{output_dir}oasis_mct3_input", exist_ok=True) + makedirs(f"{output_dir}openifs_input_modified", exist_ok=True) + makedirs(f"{output_dir}plots", exist_ok=True) + makedirs(f"{output_dir}runoff_map_modified", exist_ok=True) \ No newline at end of file diff --git a/run_ocp_tool.py b/run_ocp_tool.py index 260178c..17ba5b1 100644 --- a/run_ocp_tool.py +++ b/run_ocp_tool.py @@ -12,6 +12,7 @@ import sys import time +from os import makedirs from pathlib import Path from ocp_tool.config import load_config, OCPConfig @@ -22,6 +23,7 @@ from ocp_tool.plotting import plot_land_sea_mask, plot_runoff_maps from ocp_tool.co2_interpolation import interpolate_co2_to_icmgg from ocp_tool.field_interpolation import interpolate_2d_fields_to_icmgg +from ocp_tool.create_outputdirs import create_outputdirs def run_ocp_tool(config: OCPConfig) -> None: @@ -39,14 +41,19 @@ def run_ocp_tool(config: OCPConfig) -> None: print(f" Ocean grid: {config.ocean.grid_name}") print(f" Ice cavities: {config.ocean.has_ice_cavities}") print(f" Experiment: {config.atmosphere.experiment_name}") - print() + # Process each resolution for resolution in config.atmosphere.resolution_list: print(f"\n{'='*60}") print(f" Processing resolution T{resolution}") + print(f" Output: ./output/TCO{resolution}_{config.ocean.grid_name}") print(f"{'='*60}\n") + # Step 0: Create Output directories + print("Step 0: Creating output directories...") + create_outputdirs(config, resolution) + # Step 1: Generate Gaussian grid print("Step 1: Generating Gaussian grid coordinates...") grid = generate_gaussian_grid(config, resolution)