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 @@
- + User Manual - + Quick Start @@ -40,13 +40,16 @@ make recreate Using uv (recommended) ```bash -uv sync --extra copernicus +uv sync ``` -For development installation with extras: +For development, testing, and optional Copernicus tooling: ```bash -uv sync --extra copernicus --extra dev --extra test --extra docs +uv sync --group dev +uv sync --group dev --extra copernicus +uv run pytest -q +uv build ``` @@ -61,15 +64,17 @@ python -m pip install -e . ## Docs -See `docs/user_manual.md` for full CLI usage and end-to-end workflows. +See [docs/user_guide/README.md](docs/user_guide/README.md) for usage and workflows, and [docs/developer_guide/contributing.md](docs/developer_guide/contributing.md) for contributor commands. ## Container grid configuration At startup the container checks for grid files in this order: -1. `GRID_PATH` (or `grid_path`) if it points to an existing `*.geojson` +1. `GRID_PATH` (or `grid_path`) if it points to an existing in-container `*.geojson` 2. First `*.geojson` found in `/workspace/grid` -3. Generate a grid on startup only if no `*.geojson` is available + +If neither exists, the container exits with an error. Automatic grid generation +on startup has been removed. To use a mounted grid: @@ -82,8 +87,11 @@ docker compose up For direct `docker run`, pass an explicit in-container path when needed: ```bash -export GRID_PATH=/workspace/grid/my_region.geojson -docker run --rm -e GRID_PATH=$GRID_PATH ... +docker run --rm \ + -v "$PWD/grid:/workspace/grid:ro" \ + -e GRID_PATH=/workspace/grid/my_region.geojson \ + sirbastiano94/sarpyx:latest \ + /usr/local/bin/start-jupyter.sh ``` You can also pass `--grid-path` to the `worldsar` CLI command. diff --git a/Singularity.def b/Singularity.def deleted file mode 100644 index ba76eb4..0000000 --- a/Singularity.def +++ /dev/null @@ -1,169 +0,0 @@ -Bootstrap: docker -From: ubuntu:22.04 -Stage: builder - -%files - support/snap-install.sh /tmp/snap-install.sh - support/snap.varfile /tmp/snap.varfile - pyproject.toml /workspace/pyproject.toml - sarpyx /workspace/sarpyx - -%post - #!/bin/bash - set -eu - - echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/99root - - export DEBIAN_FRONTEND=noninteractive - export TZ=Etc/UTC - - export JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64" - export SNAP_VERSION=12.0.0 - export SNAP_HOME="/snap12" - export SNAP_SKIP_UPDATES=1 - export PATH="${PATH}:${SNAP_HOME}/bin" - - export PIP_DISABLE_PIP_VERSION_CHECK=1 - export PIP_NO_CACHE_DIR=1 - - apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - software-properties-common \ - gnupg \ - gpg-agent \ - lsb-release \ - && 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 \ - python3.11 \ - python3.11-venv \ - python3.11-dev \ - curl \ - wget \ - git \ - build-essential \ - openjdk-8-jdk \ - libhdf5-dev \ - libxml2-dev \ - libxslt1-dev \ - libproj-dev \ - libgeos-dev \ - && rm -rf /var/lib/apt/lists/* - - 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/* - - 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 - - cd /workspace - python3.11 -m pip install --no-cache-dir . - python3.11 -c "import sarpyx; print('sarpyx installed successfully')" - -Bootstrap: docker -From: ubuntu:22.04 -Stage: runtime - -%labels - Author yourname - Description SNAP + sarpyx runtime container - -%environment - export DEBIAN_FRONTEND=noninteractive - export TZ=Etc/UTC - export LANG=en_US.UTF-8 - export LANGUAGE=en_US:en - export LC_ALL=en_US.UTF-8 - export JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64" - export SNAP_HOME="/workspace/snap12" - export SNAP_SKIP_UPDATES=1 - export PATH="${PATH}:${SNAP_HOME}/bin" - export PIP_DISABLE_PIP_VERSION_CHECK=1 - export PIP_NO_CACHE_DIR=1 - -%files from builder - /snap12 /workspace/snap12 - -%files - pyproject.toml /workspace/pyproject.toml - sarpyx /workspace/sarpyx - tests /workspace/tests - entrypoint.sh /usr/local/bin/entrypoint.sh - start-jupyter.sh /usr/local/bin/start-jupyter.sh - -%post - #!/bin/bash - set -eu - - echo 'APT::Sandbox::User "root";' > /etc/apt/apt.conf.d/99root - - export DEBIAN_FRONTEND=noninteractive - export TZ=Etc/UTC - export LANG=en_US.UTF-8 - export LANGUAGE=en_US:en - export LC_ALL=en_US.UTF-8 - - export JAVA_HOME="/usr/lib/jvm/java-8-openjdk-amd64" - export SNAP_HOME="/workspace/snap12" - export SNAP_SKIP_UPDATES=1 - export PATH="${PATH}:${SNAP_HOME}/bin" - - export PIP_DISABLE_PIP_VERSION_CHECK=1 - export PIP_NO_CACHE_DIR=1 - - 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/* - - ln -sf /usr/bin/python3.11 /usr/bin/python3 - ln -sf /usr/bin/python3.11 /usr/bin/python - - 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 - - ln -sf /workspace/snap12/bin/snap /usr/local/bin/snap - ln -sf /workspace/snap12/bin/gpt /usr/local/bin/gpt - - cd /workspace - 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/* - - chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/start-jupyter.sh - -%runscript - cd /workspace - if [ "$#" -eq 0 ]; then - exec /usr/local/bin/entrypoint.sh /bin/bash - else - exec /usr/local/bin/entrypoint.sh "$@" - fi - -%startscript - cd /workspace - if [ "$#" -eq 0 ]; then - exec /usr/local/bin/entrypoint.sh /bin/bash - else - exec /usr/local/bin/entrypoint.sh "$@" - fi - -%test - python3.11 -c "import sarpyx; print('sarpyx OK')" diff --git a/docker-compose.yml b/docker-compose.yml index e0dbdb9..47cba67 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,7 @@ services: volumes: - ./data:/workspace/data - ./output:/workspace/output - # Optional: mount a directory containing one or more *.geojson grids - # - ./grid:/workspace/grid + - ./grid:/workspace/grid:ro - ./notebooks:/workspace/notebooks - ./src:/workspace/src - ./examples:/workspace/examples @@ -20,7 +19,7 @@ services: - JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 - LD_LIBRARY_PATH=.:/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/server/ - LC_ALL=en_US.UTF-8 - - PATH=/workspace/snap12/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + - PATH=/workspace/snap13/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin # Optional: set GRID_PATH to select a specific mounted *.geojson # - GRID_PATH=/workspace/grid/my_region.geojson - JUPYTER_ENABLE_LAB=yes diff --git a/docs/README.md b/docs/README.md index dde3c47..6ef7522 100755 --- a/docs/README.md +++ b/docs/README.md @@ -68,7 +68,7 @@ pip install -e . ### Container note -For container workflows, mount or otherwise provide any `*.geojson` grid in `/workspace/grid` (or set `GRID_PATH` to a specific `*.geojson`). The entrypoint will load an existing grid first and only generate one at startup if none is available. +For container workflows, mount or otherwise provide any `*.geojson` grid in `/workspace/grid` (or set `GRID_PATH` to a specific in-container `*.geojson`). The entrypoint loads an existing grid and now fails fast if none is available; it no longer generates one automatically. ## Support and Community diff --git a/docs/_site/developer_guide/contributing.html b/docs/_site/developer_guide/contributing.html index b237aee..ef9873b 100644 --- a/docs/_site/developer_guide/contributing.html +++ b/docs/_site/developer_guide/contributing.html @@ -230,16 +230,9 @@

Running Tests

Documentation

Building Documentation

-

We use Sphinx for documentation generation:

-
# Install documentation dependencies
-pip install -e ".[docs]"
-
-# Build documentation
-cd docs
-make html
-
-# Serve documentation locally
-python -m http.server 8000 -d _build/html
+

The checked-in source docs live under 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 @@