diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..3c2d92f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,17 @@
+.git
+.github
+.venv
+.pytest_cache
+__pycache__/
+*.py[cod]
+*.egg-info/
+build/
+dist/
+docs/
+grid/
+notebooks/
+outputs/
+ppt/
+src/
+tests/__pycache__/
+tutorials/
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 9f1f734..a290ab1 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -2,22 +2,39 @@
# CI/CD – Docker build, test, and publish
# ──────────────────────────────────────────────────────────────
# Triggers:
-# • Pushes to main that touch Docker-related files → build + test + push :latest
-# • Manual dispatch from main → build + test + optional push
+# • Pull requests touching Docker/runtime files → build + smoke test
+# • Pushes to main touching Docker/runtime files → build + smoke test + push tested image
+# • Manual dispatch → build + smoke test + optional push of tested image
# ──────────────────────────────────────────────────────────────
name: Docker Build & Publish
on:
+ pull_request:
+ paths:
+ - "Dockerfile"
+ - "docker-compose.yml"
+ - ".dockerignore"
+ - "entrypoint.sh"
+ - "start-jupyter.sh"
+ - "pyproject.toml"
+ - "sarpyx/**"
+ - "tests/**"
+ - "support/**"
+ - ".github/workflows/docker.yml"
push:
branches: [main]
paths:
- "Dockerfile"
- "docker-compose.yml"
+ - ".dockerignore"
+ - "entrypoint.sh"
+ - "start-jupyter.sh"
- "sarpyx/**"
- "tests/**"
- "pyproject.toml"
- "support/**"
+ - ".github/workflows/docker.yml"
workflow_dispatch:
inputs:
push_image:
@@ -25,15 +42,18 @@ on:
required: false
default: "false"
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
env:
DOCKER_IMAGE: sirbastiano94/sarpyx
+ DOCKER_TEST_GRID: /workspace/grid/grid_smoke.geojson
jobs:
- # ──────────────────── Build & Test ────────────────────────
- build-and-test:
+ docker:
runs-on: ubuntu-latest
- if: github.ref == 'refs/heads/main'
- timeout-minutes: 260
+ timeout-minutes: 180
steps:
- name: Checkout repository
@@ -44,84 +64,86 @@ jobs:
with:
version: v0.11.2
- - name: Cache Docker layers
- uses: actions/cache@v4
- with:
- path: /tmp/.buildx-cache
- key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', 'pyproject.toml') }}
- restore-keys: |
- ${{ runner.os }}-buildx-
-
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
load: true
+ platforms: linux/amd64
tags: ${{ env.DOCKER_IMAGE }}:ci
- cache-from: type=local,src=/tmp/.buildx-cache
- cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
+ cache-from: type=gha,scope=sarpyx-docker
+ cache-to: type=gha,mode=max,scope=sarpyx-docker
- # - name: Run Docker build tests (disabled)
- # run: |
- # docker run --rm ${{ env.DOCKER_IMAGE }}:ci sh -c "\
- # python3.11 -m pip install pytest && \
- # python3.11 -m pytest /workspace/tests/test_docker.py -v --tb=short"
-
- - name: Move cache # prevents ever-growing cache
+ - name: Run mounted Docker smoke tests
run: |
- rm -rf /tmp/.buildx-cache
- mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-
- # ──────────────────── Publish ─────────────────────────────
- publish:
- needs: build-and-test
- runs-on: ubuntu-latest
- if: >-
- github.ref == 'refs/heads/main' &&
- (
- github.event_name == 'push' ||
- (github.event_name == 'workflow_dispatch' && github.event.inputs.push_image == 'true')
- )
- timeout-minutes: 60
-
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- with:
- version: v0.11.2
+ docker run --rm \
+ -e GRID_PATH="${GRID_PATH}" \
+ -v "${GITHUB_WORKSPACE}/tests:/opt/smoke-tests:ro" \
+ -v "${GITHUB_WORKSPACE}/tests/fixtures:/workspace/grid:ro" \
+ ${{ env.DOCKER_IMAGE }}:ci \
+ sh -lc "python3.11 -m pip install --no-cache-dir pytest==8.4.0 && python3.11 -m pytest /opt/smoke-tests/test_docker.py -q --tb=short"
+ env:
+ GRID_PATH: ${{ env.DOCKER_TEST_GRID }}
+ shell: bash
+
+ - name: Start Jupyter smoke container
+ run: |
+ docker run -d --rm \
+ --name sarpyx-jupyter-smoke \
+ -p 127.0.0.1:8888:8888 \
+ -e GRID_PATH="${GRID_PATH}" \
+ -e JUPYTER_TOKEN=ci-smoke-token \
+ -v "${GITHUB_WORKSPACE}/tests/fixtures:/workspace/grid:ro" \
+ ${{ env.DOCKER_IMAGE }}:ci \
+ /usr/local/bin/start-jupyter.sh
+ env:
+ GRID_PATH: ${{ env.DOCKER_TEST_GRID }}
+ shell: bash
+
+ - name: Wait for Jupyter health check
+ run: |
+ for attempt in {1..30}; do
+ if curl -fsS "http://127.0.0.1:8888/lab?token=ci-smoke-token" >/dev/null; then
+ exit 0
+ fi
+ sleep 5
+ done
+ docker logs sarpyx-jupyter-smoke
+ exit 1
+ shell: bash
+
+ - name: Dump Jupyter smoke logs
+ if: always()
+ run: docker logs sarpyx-jupyter-smoke || true
+
+ - name: Remove Jupyter smoke container
+ if: always()
+ run: docker rm -f sarpyx-jupyter-smoke || true
- name: Log in to Docker Hub
+ if: >-
+ (
+ github.event_name == 'push' ||
+ (github.event_name == 'workflow_dispatch' && github.event.inputs.push_image == 'true')
+ )
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Extract metadata (tags, labels)
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: ${{ env.DOCKER_IMAGE }}
- tags: |
- type=raw,value=latest,enable={{is_default_branch}}
- type=sha,prefix=
-
- - name: Build and push Docker image
- uses: docker/build-push-action@v6
- with:
- context: .
- file: Dockerfile
- push: true
- platforms: linux/amd64
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
- cache-from: type=local,src=/tmp/.buildx-cache
- cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
-
- - name: Move cache
+ - name: Tag and push tested Docker image
+ if: >-
+ (
+ github.event_name == 'push' ||
+ (github.event_name == 'workflow_dispatch' && github.event.inputs.push_image == 'true')
+ )
run: |
- rm -rf /tmp/.buildx-cache
- mv /tmp/.buildx-cache-new /tmp/.buildx-cache
+ docker tag "${{ env.DOCKER_IMAGE }}:ci" "${{ env.DOCKER_IMAGE }}:${GITHUB_SHA}"
+ docker push "${{ env.DOCKER_IMAGE }}:${GITHUB_SHA}"
+
+ if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
+ docker tag "${{ env.DOCKER_IMAGE }}:ci" "${{ env.DOCKER_IMAGE }}:latest"
+ docker push "${{ env.DOCKER_IMAGE }}:latest"
+ fi
+ shell: bash
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a03907f..1534147 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,37 +1,68 @@
-# GitHub Actions workflow for publishing a Python package from main only.
name: Publish Python Package to PyPI
on:
- workflow_dispatch:
+ push:
+ tags:
+ - "v*"
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
jobs:
pypi-publish:
name: PyPI Release Publication
runs-on: ubuntu-latest
permissions:
+ contents: read
id-token: write # Required for trusted publishing
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Configure Python Environment
- uses: actions/setup-python@v4
+ uses: actions/setup-python@v5
with:
- python-version: "3.x"
+ python-version: "3.12"
- - name: Install GDAL System Dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y gdal-bin libgdal-dev
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v4
- - name: Configure PDM Environment
- uses: pdm-project/setup-pdm@v4
- with:
- python-version: "3.x"
- cache: true
+ - name: Sync environment
+ run: uv sync --group dev
+
+ - name: Run tests
+ run: uv run pytest -q
- name: Build Python Package
- run: pdm build
+ run: uv build
+
+ - name: Audit built wheel contents
+ run: |
+ python - <<'PY'
+ from pathlib import Path
+ import zipfile
+
+ wheels = sorted(Path("dist").glob("*.whl"))
+ if not wheels:
+ raise SystemExit("No wheel produced in dist/")
+
+ forbidden_roots = {"docs", "tests", "outputs", "pyscripts"}
+ with zipfile.ZipFile(wheels[0]) as wheel:
+ offenders = sorted(
+ name for name in wheel.namelist()
+ if name.split("/", 1)[0] in forbidden_roots
+ )
+
+ if offenders:
+ print("Forbidden wheel entries detected:")
+ for name in offenders:
+ print(name)
+ raise SystemExit(1)
+ PY
+
+ - name: Validate distributions
+ run: uvx twine check dist/*
- name: Upload Distributions to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml
new file mode 100644
index 0000000..2a817a5
--- /dev/null
+++ b/.github/workflows/python-ci.yml
@@ -0,0 +1,61 @@
+name: Python CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test-build:
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v4
+
+ - name: Sync environment
+ run: uv sync --group dev
+
+ - name: Run tests
+ run: uv run pytest -q
+
+ - name: Build distributions
+ run: uv build
+
+ - name: Audit built wheel contents
+ run: |
+ python - <<'PY'
+ from pathlib import Path
+ import zipfile
+
+ wheels = sorted(Path("dist").glob("*.whl"))
+ if not wheels:
+ raise SystemExit("No wheel produced in dist/")
+
+ forbidden_roots = {"docs", "tests", "outputs", "pyscripts"}
+ with zipfile.ZipFile(wheels[0]) as wheel:
+ offenders = sorted(
+ name for name in wheel.namelist()
+ if name.split("/", 1)[0] in forbidden_roots
+ )
+
+ if offenders:
+ print("Forbidden wheel entries detected:")
+ for name in offenders:
+ print(name)
+ raise SystemExit(1)
+ PY
diff --git a/.gitignore b/.gitignore
index 619e6d8..d5d91ea 100755
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,8 @@ snap12/
*.csv
*.txt
*.geojson
+!tests/fixtures/grid_smoke.geojson
+!tests/fixtures/sentinel_smoke_grid.geojson
AGENTS.md
diff --git a/Dockerfile b/Dockerfile
index a23e142..8558116 100755
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,20 +1,22 @@
-# ===== Builder Stage =====
-FROM ubuntu:22.04 AS builder
+# ===== Base Stage =====
+FROM ubuntu:22.04 AS base
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Etc/UTC
+ENV LANG=en_US.UTF-8
+ENV LANGUAGE=en_US:en
+ENV LC_ALL=en_US.UTF-8
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
-ARG SNAP_VERSION=12.0.0
ARG SNAP_SKIP_UPDATES=1
ENV JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64"
-ENV SNAP_HOME="/snap12"
+ENV SNAP_HOME="/workspace/snap13"
ENV SNAP_SKIP_UPDATES="${SNAP_SKIP_UPDATES}"
ENV PATH="${PATH}:${SNAP_HOME}/bin"
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV PIP_NO_CACHE_DIR=1
-# Install build dependencies
+# Install shared system dependencies once for SNAP + Python runtime.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
software-properties-common \
@@ -24,6 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& add-apt-repository -y ppa:deadsnakes/ppa \
&& add-apt-repository -y ppa:openjdk-r/ppa \
&& apt-get update && apt-get install -y --no-install-recommends \
+ locales \
python3.11 \
python3.11-venv \
python3.11-dev \
@@ -31,101 +34,72 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
wget \
git \
build-essential \
+ gfortran \
openjdk-8-jdk \
+ gdal-bin \
+ libgdal30 \
+ libfftw3-dev \
+ libtiff5-dev \
+ libgfortran5 \
+ jblas \
libhdf5-dev \
libxml2-dev \
libxslt1-dev \
libproj-dev \
libgeos-dev \
- && rm -rf /var/lib/apt/lists/*
-
-# Install SNAP
-COPY support/snap-install.sh /tmp/snap-install.sh
-COPY support/snap.varfile /tmp/snap.varfile
-RUN sed -i "s|^sys.installationDir=.*|sys.installationDir=${SNAP_HOME}|" /tmp/snap.varfile \
- && chmod +x /tmp/snap-install.sh \
- && /tmp/snap-install.sh -v \
- && rm -f /tmp/snap-install.sh /tmp/snap.varfile \
- && rm -rf /var/lib/apt/lists/*
-
-# Install pip
-RUN curl -fsSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py && \
- python3.11 /tmp/get-pip.py && \
- rm -f /tmp/get-pip.py
-
-WORKDIR /workspace
-
-# Copy and build sarpyx package
-COPY pyproject.toml ./
-COPY sarpyx ./sarpyx
-RUN python3.11 -m pip install --no-cache-dir . && \
- python3.11 -c "import sarpyx; print('sarpyx installed successfully')"
-
-# ===== Runtime Stage =====
-FROM ubuntu:22.04
-
-ENV DEBIAN_FRONTEND=noninteractive
-ENV TZ=Etc/UTC
-ENV LANG=en_US.UTF-8
-ENV LANGUAGE=en_US:en
-ENV LC_ALL=en_US.UTF-8
-SHELL ["/bin/bash", "-o", "pipefail", "-c"]
-
-ARG SNAP_SKIP_UPDATES=1
-ENV JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64"
-ENV SNAP_HOME="/workspace/snap12"
-ENV SNAP_SKIP_UPDATES="${SNAP_SKIP_UPDATES}"
-ENV PATH="${PATH}:${SNAP_HOME}/bin"
-ENV PIP_DISABLE_PIP_VERSION_CHECK=1
-ENV PIP_NO_CACHE_DIR=1
-
-# Install only runtime dependencies (no build-essential, python3.11-dev, etc.)
-RUN apt-get update && apt-get install -y --no-install-recommends \
- ca-certificates \
- python3.11 \
- curl \
- locales \
- openjdk-8-jdk \
- gdal-bin \
- libgdal30 \
&& locale-gen en_US.UTF-8 \
&& update-locale LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean \
&& rm -rf /tmp/* /var/tmp/*
-# Create symlinks
+# Create Python aliases expected by scripts and users.
RUN ln -sf /usr/bin/python3.11 /usr/bin/python3 && \
ln -sf /usr/bin/python3.11 /usr/bin/python
-# Install pip for Python 3.11 in runtime image
+# Install pip for Python 3.11.
RUN curl -fsSL https://bootstrap.pypa.io/get-pip.py -o /tmp/get-pip.py && \
python3.11 /tmp/get-pip.py && \
rm -f /tmp/get-pip.py
WORKDIR /workspace
-# Bring SNAP installation from builder stage into final image.
-COPY --from=builder /snap12 /workspace/snap12
-RUN ln -sf /workspace/snap12/bin/snap /usr/local/bin/snap && \
- ln -sf /workspace/snap12/bin/gpt /usr/local/bin/gpt
+# ===== SNAP Stage =====
+FROM base AS snap
+
+ARG SNAP_VERSION=13.0.0
+ENV SNAP_HOME="/snap13"
+ENV PATH="${PATH}:${SNAP_HOME}/bin"
+ENV SNAP_SKIP_SYSTEM_PACKAGES=1
+
+COPY support/snap-install.sh /tmp/snap-install.sh
+COPY support/snap.varfile /tmp/snap.varfile
+RUN SNAP_MAJOR="${SNAP_VERSION%%.*}" \
+ && sed -i "s/^VERSION=.*/VERSION=${SNAP_MAJOR}/" /tmp/snap-install.sh \
+ && sed -i "s|^sys.installationDir=.*|sys.installationDir=${SNAP_HOME}|" /tmp/snap.varfile \
+ && chmod +x /tmp/snap-install.sh \
+ && /tmp/snap-install.sh -v \
+ && rm -f /tmp/snap-install.sh /tmp/snap.varfile
+
+# ===== Runtime Stage =====
+FROM base
+
+# Bring SNAP installation from the dedicated SNAP stage into the final image.
+COPY --from=snap /snap13 /workspace/snap13
+RUN ln -sf /workspace/snap13/bin/snap /usr/local/bin/snap && \
+ ln -sf /workspace/snap13/bin/gpt /usr/local/bin/gpt
-# Copy only essential files
-COPY pyproject.toml ./
+COPY README.md pyproject.toml ./
COPY sarpyx ./sarpyx
-COPY tests ./tests
-# Install sarpyx in development mode and verify import
+# Install sarpyx once in the final image.
RUN python3.11 -m pip install --upgrade pip setuptools wheel && \
- python3.11 -m pip install -e . && \
- python3.11 -c "import six; print('six installed successfully')" && \
- python3.11 -m pip install --ignore-installed --no-cache-dir six python-dateutil && \
- python3.11 -c "import six, dateutil; print('runtime deps ok')" && \
- rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* /var/tmp/*
+ python3.11 -m pip install --no-cache-dir . && \
+ python3.11 -c "import sarpyx; print('sarpyx installed successfully')" && \
+ rm -rf /tmp/* /var/tmp/*
-# Copy entrypoint script
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
COPY start-jupyter.sh /usr/local/bin/start-jupyter.sh
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/start-jupyter.sh
-RUN /usr/local/bin/entrypoint.sh
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["/bin/bash"]
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..c908243
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,12 @@
+include LICENSE
+include README.md
+include pyproject.toml
+recursive-include sarpyx *.py
+prune build
+prune dist
+prune docs/_site
+prune outputs
+prune sarpyx.egg-info
+global-exclude __pycache__
+global-exclude *.py[cod]
+global-exclude .DS_Store
diff --git a/Makefile b/Makefile
index 5293e14..8e6e05b 100644
--- a/Makefile
+++ b/Makefile
@@ -5,7 +5,7 @@ SHELL := /bin/bash
# ---------- Config ----------
SUDO ?= sudo
-DOCKER ?= $(SUDO) docker
+DOCKER ?= docker
COMPOSE_BAKE ?= false
COMPOSE_FILE ?= docker-compose.yml
COMPOSE ?= COMPOSE_BAKE=$(COMPOSE_BAKE) $(DOCKER) compose -f $(COMPOSE_FILE)
@@ -14,6 +14,41 @@ DOCKER_IMAGE ?= sirbastiano94/sarpyx
DOCKER_TAG ?= latest
DOCKER_FULL := $(DOCKER_IMAGE):$(DOCKER_TAG)
PLATFORM ?= linux/amd64
+SMOKE_TEST_GRID ?= $(CURDIR)/tests/fixtures/grid_smoke.geojson
+SMOKE_TEST_GRID_CONTAINER ?= /workspace/grid/grid_smoke.geojson
+SMOKE_TESTS_DIR ?= /opt/smoke-tests
+VALIDATE_GRID ?= tests/fixtures/sentinel_smoke_grid.geojson
+SENTINEL_VALIDATE_SAFE ?= data/S1C_IW_SLC__1SDV_20260130T152608_20260130T152634_006135_00C4FA_664F.SAFE
+SENTINEL_VALIDATE_GRID ?= $(VALIDATE_GRID)
+SENTINEL_VALIDATE_OUT ?= outputs/validate-sentinel/processed
+SENTINEL_VALIDATE_CUTS ?= outputs/validate-sentinel/cuts
+SENTINEL_VALIDATE_DB ?= outputs/validate-sentinel/db
+SENTINEL_VALIDATE_SNAP_USERDIR ?= outputs/validate-sentinel/snap-userdir
+TSX_VALIDATE_PRODUCT ?= data/TSX_OPER_SAR_HS_EEC_20071130T165208_N51-485_E011-982_0000_v0104.SIP.ZIP
+TSX_VALIDATE_GRID ?= $(VALIDATE_GRID)
+TSX_VALIDATE_OUT ?= outputs/validate-tsx/subset
+TSX_VALIDATE_PREPROCESS_OUT ?= outputs/validate-tsx/processed
+TSX_VALIDATE_CUTS ?= outputs/validate-tsx/cuts
+TSX_VALIDATE_DB ?= outputs/validate-tsx/db
+TSX_VALIDATE_SNAP_USERDIR ?= outputs/validate-tsx/snap-userdir
+TSX_VALIDATE_SUBSET_NAME ?= TSX_VALIDATE_SUBSET
+TSX_VALIDATE_SUBSET_REGION ?= 0,0,2048,2048
+TSX_VALIDATE_SUBSET_WKT_FILE ?= $(TSX_VALIDATE_OUT)/$(TSX_VALIDATE_SUBSET_NAME).wkt
+NISAR_VALIDATE_PRODUCT ?= data/NISAR_GSLC_SAMPLE.h5
+NISAR_VALIDATE_GRID ?= $(VALIDATE_GRID)
+NISAR_VALIDATE_OUT ?= outputs/validate-nisar/processed
+NISAR_VALIDATE_CUTS ?= outputs/validate-nisar/cuts
+NISAR_VALIDATE_DB ?= outputs/validate-nisar/db
+SENTINEL_GPT_PATH ?= /Applications/esa-snap/bin/gpt
+SENTINEL_VALIDATE_IW ?= IW1
+SENTINEL_VALIDATE_BURST ?= 1
+SENTINEL_VALIDATE_TC_SOURCE_BAND ?= Alpha
+# Optional manual override for the Sentinel validation footprint.
+# Leave blank to derive expected coverage from the processed *_TC.dim raster.
+SENTINEL_VALIDATE_WKT ?=
+SENTINEL_GPT_MEMORY ?= 8G
+SENTINEL_GPT_PARALLELISM ?= 1
+SENTINEL_GPT_TIMEOUT ?= 14400
SIF ?= sarpyx.sif
SIF_TMPDIR ?= $(CURDIR)/.singularity/tmp
@@ -31,9 +66,10 @@ SNAP_INSTALL_PARENT ?= $(CURDIR)
# ---------- Meta ----------
.PHONY: help \
check-docker check-compose check-uv check-wget check-singularity check-hf \
- check-grid \
+ check-grid check-sentinel-product check-sentinel-grid check-sentinel-gpt check-tsx-product check-tsx-grid check-nisar-product check-nisar-grid \
clean-venv venv install-deps install-phidown setup \
install-snap \
+ validate-sentinel validate-tsx validate-nisar validate validate-all \
docker-build docker-test docker-push docker-all prune-docker \
recreate up-recreate up down logs ps pull push \
push-to-hpc \
@@ -59,21 +95,48 @@ check-docker: ## Verify docker CLI is available
check-compose: check-docker ## Verify docker compose plugin is available
@$(DOCKER) compose version >/dev/null 2>&1 || { echo "Error: docker compose plugin not available."; exit 1; }
-check-grid: ## Verify a GRID_PATH exists or fallback grid file is present
+check-grid: ## Verify an external GRID_PATH or a host grid file exists
@if [ -n "${GRID_PATH}" ]; then \
- if [ -f "${GRID_PATH}" ]; then \
+ if [ -f "${GRID_PATH}" ] && [[ "${GRID_PATH}" == *.geojson ]]; then \
echo "Using GRID_PATH=${GRID_PATH}"; \
else \
- echo "Error: GRID_PATH is set but file does not exist: ${GRID_PATH}"; \
+ echo "Error: GRID_PATH must point to an existing .geojson file: ${GRID_PATH}"; \
exit 1; \
fi; \
- elif [ -f "./grid/grid_10km.geojson" ]; then \
- echo "Using default host grid: ./grid/grid_10km.geojson"; \
+ elif compgen -G "./grid/*.geojson" > /dev/null; then \
+ echo "Using host grid directory ./grid"; \
else \
- echo "Grid file not found. Generating it now..."; \
- $(MAKE) generate-grid; \
+ echo "Error: no host grid file found in ./grid and GRID_PATH is unset."; \
+ echo "Hint: run 'make generate-grid' to create ./grid/grid_10km.geojson manually."; \
+ exit 1; \
fi
+check-sentinel-product: ## Verify the Sentinel-1 smoke SAFE product exists
+ @test -d "$(SENTINEL_VALIDATE_SAFE)" || { echo "Error: Sentinel SAFE product not found: $(SENTINEL_VALIDATE_SAFE)"; exit 1; }
+ @test -f "$(SENTINEL_VALIDATE_SAFE)/manifest.safe" || { echo "Error: Sentinel manifest not found: $(SENTINEL_VALIDATE_SAFE)/manifest.safe"; exit 1; }
+
+check-sentinel-grid: ## Verify the Sentinel-1 smoke grid exists
+ @test -f "$(SENTINEL_VALIDATE_GRID)" || { echo "Error: Sentinel smoke grid not found: $(SENTINEL_VALIDATE_GRID)"; exit 1; }
+
+check-sentinel-gpt: ## Verify SNAP GPT for Sentinel validation is available
+ @test -x "$(SENTINEL_GPT_PATH)" || { \
+ echo "Error: SNAP GPT executable not found: $(SENTINEL_GPT_PATH)"; \
+ echo "Set SENTINEL_GPT_PATH to the SNAP gpt executable or run 'make install-snap'."; \
+ exit 1; \
+ }
+
+check-tsx-product: ## Verify the TerraSAR-X validation product exists
+ @test -e "$(TSX_VALIDATE_PRODUCT)" || { echo "Error: TerraSAR-X product not found: $(TSX_VALIDATE_PRODUCT)"; exit 1; }
+
+check-tsx-grid: ## Verify the TerraSAR-X validation grid exists
+ @test -f "$(TSX_VALIDATE_GRID)" || { echo "Error: TerraSAR-X validation grid not found: $(TSX_VALIDATE_GRID)"; exit 1; }
+
+check-nisar-product: ## Verify the NISAR validation product exists
+ @test -f "$(NISAR_VALIDATE_PRODUCT)" || { echo "Error: NISAR product not found: $(NISAR_VALIDATE_PRODUCT)"; exit 1; }
+
+check-nisar-grid: ## Verify the NISAR validation grid exists
+ @test -f "$(NISAR_VALIDATE_GRID)" || { echo "Error: NISAR validation grid not found: $(NISAR_VALIDATE_GRID)"; exit 1; }
+
check-uv: ## Verify uv is available
@command -v uv >/dev/null 2>&1 || { echo "Error: uv not found in PATH."; exit 1; }
@@ -176,9 +239,14 @@ docker-build: check-docker ## Build Docker image
docker-test: docker-build ## Run containerized Docker tests
@echo "Running Docker build tests..."
- $(DOCKER) run --rm "$(DOCKER_FULL)" sh -c "\
- python3 -m pip install pytest && \
- python3 -m pytest /workspace/tests/test_docker.py -v --tb=short"
+ @test -f "$(SMOKE_TEST_GRID)" || { echo "Error: smoke test grid missing at $(SMOKE_TEST_GRID)"; exit 1; }
+ $(DOCKER) run --rm \
+ -e GRID_PATH="$(SMOKE_TEST_GRID_CONTAINER)" \
+ -v "$(CURDIR)/tests:$(SMOKE_TESTS_DIR):ro" \
+ -v "$(CURDIR)/tests/fixtures:/workspace/grid:ro" \
+ "$(DOCKER_FULL)" sh -lc "\
+ python3.11 -m pip install --no-cache-dir pytest==8.4.0 && \
+ python3.11 -m pytest $(SMOKE_TESTS_DIR)/test_docker.py -v --tb=short"
docker-push: docker-build ## Push Docker image
@echo "Pushing $(DOCKER_FULL)..."
@@ -190,6 +258,75 @@ prune-docker: check-docker ## Prune local Docker data
@echo "Pruning Docker system..."
$(DOCKER) system prune -a
+validate-sentinel: check-uv check-sentinel-product check-sentinel-grid check-sentinel-gpt ## Run a smoke pipeline test on the Sentinel-1 SAFE product
+ @mkdir -p "$(SENTINEL_VALIDATE_OUT)" "$(SENTINEL_VALIDATE_CUTS)" "$(SENTINEL_VALIDATE_DB)" "$(SENTINEL_VALIDATE_SNAP_USERDIR)"
+ @sentinel_skip_preprocessing=""; \
+ if find "$(SENTINEL_VALIDATE_OUT)/$(SENTINEL_VALIDATE_IW)" -maxdepth 1 -type f -name '*_TC.dim' | grep -q .; then \
+ echo "Reusing existing Sentinel TerrainCorrection product from $(SENTINEL_VALIDATE_OUT)/$(SENTINEL_VALIDATE_IW)"; \
+ sentinel_skip_preprocessing="--skip-preprocessing"; \
+ fi; \
+ PRODUCT_WKT="$(SENTINEL_VALIDATE_WKT)" uv run sarpyx \
+ --input "$(SENTINEL_VALIDATE_SAFE)" \
+ --output "$(SENTINEL_VALIDATE_OUT)" \
+ --cuts-outdir "$(SENTINEL_VALIDATE_CUTS)" \
+ --grid-path "$(SENTINEL_VALIDATE_GRID)" \
+ --db-dir "$(SENTINEL_VALIDATE_DB)" \
+ --snap-userdir "$(SENTINEL_VALIDATE_SNAP_USERDIR)" \
+ --gpt-path "$(SENTINEL_GPT_PATH)" \
+ --gpt-memory "$(SENTINEL_GPT_MEMORY)" \
+ --gpt-parallelism "$(SENTINEL_GPT_PARALLELISM)" \
+ --gpt-timeout "$(SENTINEL_GPT_TIMEOUT)" \
+ --sentinel-swath "$(SENTINEL_VALIDATE_IW)" \
+ --sentinel-first-burst "$(SENTINEL_VALIDATE_BURST)" \
+ --sentinel-last-burst "$(SENTINEL_VALIDATE_BURST)" \
+ --sentinel-tc-source-band "$(SENTINEL_VALIDATE_TC_SOURCE_BAND)" \
+ --orbit-continue-on-fail \
+ $$sentinel_skip_preprocessing
+ @cut_report=$$(find "$(SENTINEL_VALIDATE_CUTS)" -type f -name '*_cuts_report_*.txt' | sort | head -n 1); \
+ pdf_report=$$(find "$(SENTINEL_VALIDATE_CUTS)" -maxdepth 1 -type f -name '*_h5_validation_report.pdf' | sort | head -n 1); \
+ tile_count=$$(find "$(SENTINEL_VALIDATE_CUTS)" -type f -name '*.h5' | wc -l | tr -d ' '); \
+ test -n "$$cut_report" || { echo "Error: Sentinel tile cut report not found under $(SENTINEL_VALIDATE_CUTS)"; exit 1; }; \
+ test -n "$$pdf_report" || { echo "Error: Sentinel H5 validation report not found under $(SENTINEL_VALIDATE_CUTS)"; exit 1; }; \
+ test "$$tile_count" -gt 0 || { echo "Error: Sentinel validation produced zero .h5 tiles under $(SENTINEL_VALIDATE_CUTS)"; exit 1; }; \
+ echo "Sentinel tiles generated: $$tile_count"; \
+ echo "Sentinel tile cut report: $$cut_report"; \
+ sed -n '1,14p' "$$cut_report"; \
+ echo "Sentinel H5 validation report: $$pdf_report"
+
+# TSX_VALIDATE_PRODUCT may point to either the TerraSAR-X XML metadata file or the product directory/archive.
+# TSX validation first writes a deterministic Subset region ($(TSX_VALIDATE_SUBSET_REGION)) to $(TSX_VALIDATE_OUT)
+# and then reuses that subset DIM product via --skip-preprocessing for the normal tile validation workflow.
+# If a TerrainCorrection DIM already exists in $(TSX_VALIDATE_PREPROCESS_OUT), the target reuses it and skips TSX preprocessing.
+validate-tsx: check-uv check-tsx-product check-tsx-grid check-sentinel-gpt ## Run TerraSAR-X validation using a deterministic subset before tiling
+ @mkdir -p "$(TSX_VALIDATE_PREPROCESS_OUT)" "$(TSX_VALIDATE_OUT)" "$(TSX_VALIDATE_CUTS)" "$(TSX_VALIDATE_DB)" "$(TSX_VALIDATE_SNAP_USERDIR)"
+ uv run python -c 'from pathlib import Path; import xml.etree.ElementTree as ET; from pyproj import Transformer; from sarpyx.cli import worldsar; product_path = Path(r"$(TSX_VALIDATE_PRODUCT)"); preprocess_out = Path(r"$(TSX_VALIDATE_PREPROCESS_OUT)"); subset_out = Path(r"$(TSX_VALIDATE_OUT)"); subset_name = "$(TSX_VALIDATE_SUBSET_NAME)"; subset_region = "$(TSX_VALIDATE_SUBSET_REGION)"; wkt_file = Path(r"$(TSX_VALIDATE_SUBSET_WKT_FILE)"); gpt_memory = "$(SENTINEL_GPT_MEMORY)"; gpt_parallelism = $(SENTINEL_GPT_PARALLELISM); gpt_timeout = $(SENTINEL_GPT_TIMEOUT); preprocess_out.mkdir(parents=True, exist_ok=True); subset_out.mkdir(parents=True, exist_ok=True); existing_dims = sorted(preprocess_out.glob("*.dim"), key=lambda path: path.stat().st_mtime, reverse=True); intermediate = existing_dims[0] if existing_dims else Path(worldsar.pipeline_tsx_csg(product_path, preprocess_out, gpt_memory=gpt_memory, gpt_parallelism=gpt_parallelism, gpt_timeout=gpt_timeout)); print(f"Reusing existing TSX TerrainCorrection product: {intermediate}") if existing_dims else None; subset_dim = Path(worldsar._run_gpt_op(intermediate, subset_out, "BEAM-DIMAP", "Subset", region=subset_region, copy_metadata=True, output_name=subset_name, gpt_memory=gpt_memory, gpt_parallelism=gpt_parallelism, gpt_timeout=gpt_timeout)); tree = ET.parse(subset_dim); root = tree.getroot(); gt = worldsar._read_geotransform(subset_dim); ncols = int(root.findtext(".//Raster_Dimensions/NCOLS")); nrows = int(root.findtext(".//Raster_Dimensions/NROWS")); crs_name = root.findtext(".//Coordinate_Reference_System/NAME", default="EPSG:4326"); epsg = 4326; epsg = int(crs_name.upper().split("EPSG:")[1].split()[0]) if "EPSG:" in crs_name.upper() else epsg; origin_x, px_w, rot_x, origin_y, rot_y, px_h = gt; corners = [(origin_x, origin_y), (origin_x + ncols * px_w, origin_y + ncols * rot_y), (origin_x + ncols * px_w + nrows * rot_x, origin_y + ncols * rot_y + nrows * px_h), (origin_x + nrows * rot_x, origin_y + nrows * px_h)]; corners = [Transformer.from_crs(epsg, 4326, always_xy=True).transform(x, y) for x, y in corners] if epsg != 4326 else corners; corners.append(corners[0]); wkt_file.write_text("POLYGON((" + ", ".join(f"{lon} {lat}" for lon, lat in corners) + "))\n", encoding="utf-8"); print(subset_dim); print(wkt_file)'
+ uv run sarpyx \
+ --input "$(TSX_VALIDATE_PRODUCT)" \
+ --output "$(TSX_VALIDATE_OUT)" \
+ --cuts-outdir "$(TSX_VALIDATE_CUTS)" \
+ --grid-path "$(TSX_VALIDATE_GRID)" \
+ --db-dir "$(TSX_VALIDATE_DB)" \
+ --snap-userdir "$(TSX_VALIDATE_SNAP_USERDIR)" \
+ --gpt-path "$(SENTINEL_GPT_PATH)" \
+ --gpt-memory "$(SENTINEL_GPT_MEMORY)" \
+ --gpt-parallelism "$(SENTINEL_GPT_PARALLELISM)" \
+ --gpt-timeout "$(SENTINEL_GPT_TIMEOUT)" \
+ --product-wkt "$$(tr -d '\n' < "$(TSX_VALIDATE_SUBSET_WKT_FILE)")" \
+ --skip-preprocessing
+
+validate-nisar: check-uv check-nisar-product check-nisar-grid ## Run NISAR validation using the shared worldsar tiling and H5 checks
+ @mkdir -p "$(NISAR_VALIDATE_OUT)" "$(NISAR_VALIDATE_CUTS)" "$(NISAR_VALIDATE_DB)"
+ uv run sarpyx \
+ --input "$(NISAR_VALIDATE_PRODUCT)" \
+ --output "$(NISAR_VALIDATE_OUT)" \
+ --cuts-outdir "$(NISAR_VALIDATE_CUTS)" \
+ --grid-path "$(NISAR_VALIDATE_GRID)" \
+ --db-dir "$(NISAR_VALIDATE_DB)"
+
+validate-all: validate-sentinel validate-tsx validate-nisar ## Run all mission validation targets
+
+validate: validate-all ## Alias for aggregate validation
+
# ---------- Compose ----------
compose-precheck: check-compose check-grid ## Validate docker compose and grid prerequisites
@@ -219,8 +356,6 @@ push: check-docker recreate ## Push image configured by DOCKER_IMAGE/DOCKER_TAG
push-to-hpc: ## Run HPC upload script
bash /shared/home/rdelprete/PythonProjects/srp/scripts/upload_sif.sh
-
-
# ---------- Backward-compatible aliases ----------
clean_venv: clean-venv
install_deps: install-deps
diff --git a/README.md b/README.md
index 61f9c80..f139088 100755
--- a/README.md
+++ b/README.md
@@ -4,10 +4,10 @@
We use Sphinx for documentation generation: The checked-in source docs live under For specific versions: PDM (Python Dependency Management) is the preferred tool for development: Install PDM:
+ Install uv:
-
+
-
+
@@ -40,13 +40,16 @@ make recreate
Running Tests
Documentation
Building Documentation
-# Install documentation dependencies
-pip install -e ".[docs]"
-
-# Build documentation
-cd docs
-make html
-
-# Serve documentation locally
-python -m http.server 8000 -d _build/html
+docs/. To refresh the static site:python docs/generate_static_site.py
+python -m http.server 8000 -d docs/_site
Documentation Guidelines
diff --git a/docs/_site/user_guide/installation.html b/docs/_site/user_guide/installation.html
index 603b558..a383e9b 100644
--- a/docs/_site/user_guide/installation.html
+++ b/docs/_site/user_guide/installation.html
@@ -81,15 +81,15 @@
1. Using pip (Recommended for Users)<
pip install sarpyx
pip install sarpyx==0.1.5
+
-pip install sarpyx==<version>
2. Using PDM (Recommended for Development)
-2. Using uv (Recommended for Development and CI)
+uv is the canonical tool for local installation, testing, and builds:
bash
- pip install pdm
Clone the repository: @@ -100,19 +100,20 @@
Install dependencies:
bash
- pdm install
For development with extras: +
For development and optional Copernicus tooling:
bash
- pdm install -G dev -G test -G docs
For contributors or advanced users who want the latest features:
git clone https://github.com/ESA-PhiLab/sarpyx.git
cd sarpyx
-pip install -e .
+uv sync --group dev
This creates an editable installation that reflects code changes immediately.
Verify Installation:
bash
- pip show sarpyx
- # or
- pdm list | grep sarpyx
Check Python Environment: @@ -101,11 +101,10 @@
Use PDM for Better Dependency Management: +
Use uv for Better Dependency Management:
bash
- pdm init
- pdm add sarpyx
- pdm install
Check Conflicting Packages: diff --git a/docs/api/index.html b/docs/api/index.html index c317542..eb3035a 100644 --- a/docs/api/index.html +++ b/docs/api/index.html @@ -24,7 +24,7 @@