diff --git a/Makefile b/Makefile index 7dd4d97e..da4ee879 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ sync: ## Install dependencies with uv uv sync run: openapi ## Start the app with uvicorn - uv run uvicorn climate_api.main:app --reload + uv run uvicorn climate_api.main:app --reload --reload-include "*.html" --reload-include "*.yaml" --reload-include "*.yml" lint: ## Check linting, formatting, and types (no autofix) uv run ruff check . diff --git a/src/climate_api/data/datasets/chirps3.yaml b/src/climate_api/data/datasets/chirps3.yaml index b977bb2d..7fecad80 100644 --- a/src/climate_api/data/datasets/chirps3.yaml +++ b/src/climate_api/data/datasets/chirps3.yaml @@ -7,9 +7,13 @@ sync_execution: append sync_availability: latest_available_function: climate_api.providers.availability.chirps3_daily_latest_available - ingestion: + ingestion: function: dhis2eo.data.chc.chirps3.daily.download units: mm resolution: 5 km x 5 km source: CHIRPS v3 source_url: https://www.chc.ucsb.edu/data/chirps3 + display: + colormap: blues + range: [0.0, 20.0] + nodata: -9999.0 diff --git a/src/climate_api/data/datasets/era5_land.yaml b/src/climate_api/data/datasets/era5_land.yaml index 91716520..161f6832 100644 --- a/src/climate_api/data/datasets/era5_land.yaml +++ b/src/climate_api/data/datasets/era5_land.yaml @@ -17,6 +17,9 @@ resolution: 9 km x 9 km source: ERA5-Land Reanalysis source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land + display: + colormap: rdbu_r + range: [233.0, 313.0] - id: era5land_precipitation_hourly name: Total precipitation (ERA5-Land) @@ -38,3 +41,7 @@ resolution: 9 km x 9 km source: ERA5-Land Reanalysis source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land + display: + colormap: blues + range: [0.0, 5.0] + nodata: 0.0 diff --git a/src/climate_api/data/datasets/worldpop.yaml b/src/climate_api/data/datasets/worldpop.yaml index ead982b9..b7d9f64c 100644 --- a/src/climate_api/data/datasets/worldpop.yaml +++ b/src/climate_api/data/datasets/worldpop.yaml @@ -18,3 +18,7 @@ resolution: 100m x 100m source: WorldPop Global2 source_url: https://hub.worldpop.org/project/categories?id=3 + display: + colormap: reds + range: [0.0, 25.0] + nodata: 0.0 diff --git a/src/climate_api/stac/services.py b/src/climate_api/stac/services.py index 4a0c730e..7f53347a 100644 --- a/src/climate_api/stac/services.py +++ b/src/climate_api/stac/services.py @@ -25,6 +25,7 @@ CATALOG_DESCRIPTION = "Published Climate API GeoZarr datasets" STAC_VERSION = "1.1.0" DATACUBE_EXTENSION = "https://stac-extensions.github.io/datacube/v2.3.0/schema.json" +RENDER_EXTENSION = "https://stac-extensions.github.io/render/v2.0.0/schema.json" ZARR_EXTENSION = "https://stac-extensions.github.io/zarr/v1.1.0/schema.json" DEFAULT_STAC_LICENSE = "various" SPATIAL_STEP_DECIMALS = 8 @@ -89,11 +90,16 @@ def build_collection(dataset_id: str, request: Request) -> dict[str, object]: collection_payload["stac_version"] = STAC_VERSION collection_payload["description"] = template.description collection_payload["title"] = template.title + renders = _build_renders(artifact, source_dataset) + extensions = {DATACUBE_EXTENSION, ZARR_EXTENSION} + if renders is not None: + collection_payload["renders"] = renders + extensions.add(RENDER_EXTENSION) existing_extensions = collection_payload.get("stac_extensions", []) if isinstance(existing_extensions, list): - collection_payload["stac_extensions"] = sorted({*existing_extensions, DATACUBE_EXTENSION, ZARR_EXTENSION}) + collection_payload["stac_extensions"] = sorted({*existing_extensions, *extensions}) else: - collection_payload["stac_extensions"] = sorted([DATACUBE_EXTENSION, ZARR_EXTENSION]) + collection_payload["stac_extensions"] = sorted(extensions) collection_payload["links"] = template_links assets = collection_payload.setdefault("assets", {}) zarr_from_xstac = assets.get("zarr", {}) if isinstance(assets, dict) else {} @@ -427,6 +433,30 @@ def _zarr_open_kwargs(artifact: ArtifactRecord) -> dict[str, bool | None]: return {"consolidated": _zarr_consolidated_flag(_artifact_store_path(artifact))} +def _build_renders(artifact: ArtifactRecord, source_dataset: dict[str, Any]) -> dict[str, Any] | None: + display = source_dataset.get("display") + if not isinstance(display, dict): + return None + colormap_name = display.get("colormap") + value_range = display.get("range") + if not isinstance(colormap_name, str) or not isinstance(value_range, list) or len(value_range) != 2: + return None + render: dict[str, Any] = { + "title": artifact.dataset_name, + "assets": ["zarr"], + "rescale": [[float(value_range[0]), float(value_range[1])]], + "colormap_name": colormap_name, + "climate_api:variable": artifact.variable, + } + nodata = display.get("nodata") + if nodata is not None: + render["nodata"] = float(nodata) + units = source_dataset.get("convert_units") or source_dataset.get("units") + if isinstance(units, str): + render["climate_api:units"] = units + return {"default": render} + + def _zarr_consolidated_flag(artifact_path: str) -> bool | None: if "://" in artifact_path: return None diff --git a/src/climate_api/system/routes.py b/src/climate_api/system/routes.py index e52a5911..4b9e7fe8 100644 --- a/src/climate_api/system/routes.py +++ b/src/climate_api/system/routes.py @@ -1,13 +1,15 @@ """Root API endpoints.""" import sys +import urllib.parse from importlib.metadata import version as _pkg_version from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse, Response +from starlette.responses import RedirectResponse from .schemas import AppInfo, HealthStatus, Status -from .templates import ROOT_RESPONSES, app_version, render_landing, root_json, wants_json +from .templates import ROOT_RESPONSES, app_version, render_landing, render_manage, render_maps, root_json, wants_json router = APIRouter() @@ -21,6 +23,96 @@ def read_index(request: Request) -> Response: return HTMLResponse(render_landing(app_version, base)) +@router.get("/map", response_class=HTMLResponse, include_in_schema=False) +def maps(request: Request) -> HTMLResponse: + """Return the interactive map viewer.""" + base = str(request.base_url).rstrip("/") + return HTMLResponse(render_maps(base)) + + +@router.get("/manage", response_class=HTMLResponse, include_in_schema=False) +def manage( + request: Request, + message: str | None = None, + error: str | None = None, +) -> HTMLResponse: + """Return the management interface for ingestion and sync operations.""" + base = str(request.base_url).rstrip("/") + return HTMLResponse(render_manage(app_version, base, message=message, error=error)) + + +@router.post("/manage/ingest", include_in_schema=False) +async def manage_ingest(request: Request) -> RedirectResponse: + """Handle ingest form submission and redirect to the management page.""" + from fastapi import HTTPException + + from climate_api.data_registry.services.datasets import get_dataset + from climate_api.extents.services import get_extent + from climate_api.ingestions.services import create_artifact + + base = str(request.base_url).rstrip("/") + try: + form = await request.form() + dataset_id = str(form.get("dataset_id", "")) + start = str(form.get("start", "")) + end = str(form.get("end", "")) or None + publish = "publish" in form + overwrite = "overwrite" in form + + template = get_dataset(dataset_id) + if template is None: + msg = urllib.parse.quote(f"Dataset template '{dataset_id}' not found") + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + extent = get_extent() + resolved_bbox = list(extent["bbox"]) if extent else None + extent_id = extent["id"] if extent else None + country_code = extent.get("country_code") if extent else None + + create_artifact( + dataset=template, + start=start, + end=end, + extent_id=extent_id, + bbox=resolved_bbox, + country_code=country_code, + overwrite=overwrite, + prefer_zarr=True, + publish=publish, + ) + name = urllib.parse.quote(template.get("name", dataset_id)) + return RedirectResponse(f"{base}/manage?message=Ingested+{name}", status_code=303) + except HTTPException as exc: + msg = urllib.parse.quote(str(exc.detail)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + except Exception as exc: + msg = urllib.parse.quote(str(exc)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + +@router.post("/manage/sync", include_in_schema=False) +async def manage_sync(request: Request) -> RedirectResponse: + """Handle sync form submission and redirect to the management page.""" + from fastapi import HTTPException + + from climate_api.ingestions.services import sync_dataset + + base = str(request.base_url).rstrip("/") + try: + form = await request.form() + dataset_id = str(form.get("dataset_id", "")) + publish = "publish" in form + + sync_dataset(dataset_id=dataset_id, end=None, prefer_zarr=True, publish=publish) + return RedirectResponse(f"{base}/manage?message=Sync+completed", status_code=303) + except HTTPException as exc: + msg = urllib.parse.quote(str(exc.detail)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + except Exception as exc: + msg = urllib.parse.quote(str(exc)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + @router.get("/health") def health() -> HealthStatus: """Return health status for container health checks.""" diff --git a/src/climate_api/system/templates.py b/src/climate_api/system/templates.py index 6caa992a..6cfeca77 100644 --- a/src/climate_api/system/templates.py +++ b/src/climate_api/system/templates.py @@ -2,6 +2,7 @@ import importlib.resources import logging +from datetime import date from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from typing import Any @@ -9,6 +10,7 @@ import jinja2 from fastapi import Request +from climate_api.data_registry.services import datasets as registry_datasets from climate_api.extents.services import get_extent from climate_api.ingestions.services import list_datasets @@ -94,23 +96,60 @@ def wants_json(request: Request) -> bool: return json_q >= 0 and (html_q < 0 or json_q >= html_q) -def render_landing(version: str, base: str) -> str: - """Render the root landing page with live instance status.""" +def render_maps(base: str) -> str: + """Render the map viewer page.""" + return get_template("map-viewer.html").render(base=base) + + +def _load_extent() -> dict[str, Any] | None: try: - extent: dict[str, Any] | None = get_extent() + return get_extent() except ValueError: - extent = None + return None + except Exception: + _log.exception("Unexpected error loading extent") + return None + + +def _load_templates() -> list[dict[str, Any]]: + try: + return registry_datasets.list_datasets() except Exception: - _log.exception("Unexpected error loading extent for landing page") - extent = None + _log.exception("Unexpected error loading dataset templates") + return [] + + +def _load_datasets() -> list[Any]: try: - datasets = list_datasets().items + return list_datasets().items except Exception: - _log.exception("Unexpected error loading datasets for landing page") - datasets = [] + _log.exception("Unexpected error loading datasets") + return [] + + +def render_landing(version: str, base: str) -> str: + """Render the root landing page with live instance status.""" return get_template("landing_page.html").render( version=version, base=base, - extent=extent, - datasets=datasets, + extent=_load_extent(), + datasets=_load_datasets(), + templates=_load_templates(), + ) + + +def render_manage(version: str, base: str, message: str | None = None, error: str | None = None) -> str: + """Render the management page.""" + today = date.today().isoformat() + year_ago = date.today().replace(year=date.today().year - 1).isoformat() + return get_template("manage.html").render( + version=version, + base=base, + extent=_load_extent(), + templates=_load_templates(), + datasets=_load_datasets(), + today=today, + year_ago=year_ago, + message=message, + error=error, ) diff --git a/src/climate_api/templates/landing_page.html b/src/climate_api/templates/landing_page.html index 49cd5999..6bd7703b 100644 --- a/src/climate_api/templates/landing_page.html +++ b/src/climate_api/templates/landing_page.html @@ -264,10 +264,53 @@

Datasets {{ datasets | length }}

{% endif %} + +
+

Available dataset templates {{ templates | length }}

+ {% if templates %} + + + + + + + + + + + {% for t in templates %} + + + + + + + {% endfor %} + +
NameVariablePeriodSource
{{ t.name }}{{ t.variable }}{{ t.period_type }} + {% if t.source_url %} + {{ t.source or t.id }} + {% else %} + {{ t.source or '—' }} + {% endif %} +
+ {% else %} +

No dataset templates found.

+ {% endif %} +
+

Explore