diff --git a/.env.example b/.env.example index 70b4b505..f04aa118 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ── Instance configuration ──────────────────────────────────────────────────── -# Path to the instance config file (extent, optional datasets_dir). +# Path to the instance config file (extent, optional templates_dir). # Copy the example before editing: cp climate-api.yaml.example climate-api.yaml # climate-api.yaml is gitignored so your local extent stays out of version control. # When running via `make run` from the repo root, the relative path below works. @@ -17,9 +17,6 @@ CLIMATE_API_CONFIG=./climate-api.yaml # See docs/setup_guide.md for registration and .netrc setup instructions. # ── Download and ingestion ──────────────────────────────────────────────────── -# Override the download cache directory (default: data/downloads). -# CACHE_OVERRIDE=/path/to/cache - # Fallback bounding box used when a request does not include an explicit bbox. # Format: xmin,ymin,xmax,ymax # DOWNLOAD_BBOX=-13.5,6.9,-10.1,10.0 diff --git a/Makefile b/Makefile index 63b8bc00..ad78f459 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/climate-api.yaml.example b/climate-api.yaml.example index a041fd84..94ad2963 100644 --- a/climate-api.yaml.example +++ b/climate-api.yaml.example @@ -8,4 +8,6 @@ extent: bbox: [-13.5, 6.9, -10.1, 10.0] country_code: SLE -# datasets_dir: ./datasets/ # optional — custom templates merged with built-ins +data_dir: ./data # required — directory for downloaded NetCDF files and Zarr stores + +# templates_dir: ./templates/ # optional — root for custom templates; datasets go in templates/datasets/ diff --git a/climate_api/config.py b/climate_api/config.py index 134976f7..56844101 100644 --- a/climate_api/config.py +++ b/climate_api/config.py @@ -7,6 +7,8 @@ import yaml +_MISSING = object() + def _substitute_env_vars(text: str) -> str: """Replace ${VAR:-default} patterns with values from the environment.""" @@ -54,3 +56,31 @@ def _load_config() -> dict[str, Any]: raise ValueError(f"CLIMATE_API_CONFIG must be a YAML mapping at the top level: {path}") _cache = dict(loaded or {}) return _cache + + +def get_data_dir() -> Path | None: + """Return the data directory declared in CLIMATE_API_CONFIG. + + Returns None when CLIMATE_API_CONFIG is unset or points to a file that does + not exist (e.g. CI environments where the config is gitignored). + + Raises ValueError if the config file exists but data_dir is not set, so + misconfigured instances fail fast at startup rather than silently sharing + a default directory with other instances. + + """ + config_path = get_config_path() + if config_path is None or not config_path.exists(): + return None + + config = get_config() + raw = config.get("data_dir", _MISSING) + if raw is _MISSING: + raise ValueError( + "data_dir is required in CLIMATE_API_CONFIG when a config file is present. " + "Set it to the directory where downloaded data should be stored, " + "e.g. data_dir: ./data" + ) + if not isinstance(raw, (str, Path)): + raise ValueError(f"data_dir in CLIMATE_API_CONFIG must be a path string, got {type(raw).__name__}") + return (config_path.parent / raw).resolve() diff --git a/climate_api/data/datasets/chirps3.yaml b/climate_api/data/datasets/chirps3.yaml index b977bb2d..998fbb89 100644 --- a/climate_api/data/datasets/chirps3.yaml +++ b/climate_api/data/datasets/chirps3.yaml @@ -7,9 +7,21 @@ sync_execution: append sync_availability: latest_available_function: climate_api.providers.availability.chirps3_daily_latest_available - ingestion: + extents: + spatial: + bbox: [-180, -50, 180, 50] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: "1981-01-01" + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + resolution: P1D + 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/climate_api/data/datasets/era5_land.yaml b/climate_api/data/datasets/era5_land.yaml index 91716520..146419c4 100644 --- a/climate_api/data/datasets/era5_land.yaml +++ b/climate_api/data/datasets/era5_land.yaml @@ -8,15 +8,28 @@ sync_availability: latest_available_function: climate_api.providers.availability.lagged_latest_available lag_hours: 120 - ingestion: + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: "1950-01-01" + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + resolution: PT1H + ingestion: function: dhis2eo.data.destine.era5_land.hourly.download default_params: variables: ['t2m'] + transforms: + - climate_api.transforms.convert_units units: kelvin convert_units: degC 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: [15.0, 40.0] - id: era5land_precipitation_hourly name: Total precipitation (ERA5-Land) @@ -28,13 +41,27 @@ sync_availability: latest_available_function: climate_api.providers.availability.lagged_latest_available lag_hours: 120 - ingestion: + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: "1950-01-01" + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + resolution: PT1H + ingestion: function: dhis2eo.data.destine.era5_land.hourly.download default_params: variables: ['tp'] - pre_process: ['deaccumulate_era5'] + transforms: + - climate_api.transforms.deaccumulate_era5 + - climate_api.transforms.convert_units units: m convert_units: mm 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/climate_api/data/datasets/worldpop.yaml b/climate_api/data/datasets/worldpop.yaml index ead982b9..78fbb99f 100644 --- a/climate_api/data/datasets/worldpop.yaml +++ b/climate_api/data/datasets/worldpop.yaml @@ -8,6 +8,15 @@ latest_available_function: climate_api.providers.availability.worldpop_release_latest_available # WorldPop projections are intentionally request-driven for future years. allow_future: true + extents: + spatial: + bbox: [-180, -90, 180, 90] + crs: http://www.opengis.net/def/crs/OGC/1.3/CRS84 + temporal: + begin: "2015" + end: "2030" + trs: http://www.opengis.net/def/uom/ISO-8601/0/Gregorian + resolution: P1Y ingestion: function: dhis2eo.data.worldpop.pop_total.yearly.download multiscales: @@ -18,3 +27,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/climate_api/data_manager/services/downloader.py b/climate_api/data_manager/services/downloader.py index 3c93772e..291f08e5 100644 --- a/climate_api/data_manager/services/downloader.py +++ b/climate_api/data_manager/services/downloader.py @@ -16,19 +16,17 @@ from geozarr_toolkit import MultiscalesConventionMetadata, create_geozarr_attrs from topozarr.coarsen import create_pyramid +from climate_api import config as api_config + from .utils import get_lon_lat_dims, get_time_dim logger = logging.getLogger(__name__) def _resolve_download_dir() -> Path: - # CACHE_OVERRIDE keeps existing Docker/dev deployments working unchanged. - override = os.getenv("CACHE_OVERRIDE") - if override: - return Path(override) - # Default to an XDG-compliant user-writable location so the package works - # when installed with pip (where a package-relative path would land inside - # site-packages and typically be non-writable). + data_dir = api_config.get_data_dir() + if data_dir is not None: + return data_dir / "downloads" xdg_data = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) return xdg_data / "climate-api" / "downloads" @@ -54,6 +52,7 @@ def download_dataset( When running in the background-task path, the download is deferred and this function returns an empty list because no files have been created yet. """ + _validate_spatial_coverage(dataset, bbox if bbox is not None else _bbox_from_env()) ingestion = dataset["ingestion"] eo_download_func_path = ingestion["function"] eo_download_func = _get_dynamic_function(eo_download_func_path) @@ -143,6 +142,7 @@ def build_dataset_zarr(dataset: dict[str, Any], *, start: str | None = None, end dims = [lon_dim, lat_dim] ds = _select_time_range(ds, dataset=dataset, start=start, end=end) + ds = _run_transforms(ds, dataset) xmin = ds[lon_dim].min().item() xmax = ds[lon_dim].max().item() @@ -243,6 +243,16 @@ def _select_time_range( return selected +def _run_transforms(ds: xr.Dataset, dataset: dict[str, Any]) -> xr.Dataset: + for entry in dataset.get("transforms", []): + func_path = entry if isinstance(entry, str) else entry["function"] + params = {} if isinstance(entry, str) else entry.get("params", {}) + func = _get_dynamic_function(func_path) + logger.info("Applying transform %s to dataset %s", func_path, dataset.get("id", "?")) + ds = func(ds, dataset, **params) + return ds + + def _compute_time_space_chunks( ds: xr.Dataset, dataset: dict[str, Any], @@ -289,6 +299,39 @@ def get_zarr_path(dataset: dict[str, Any]) -> Path | None: return None +def _validate_spatial_coverage(dataset: dict[str, Any], bbox: list[float] | None) -> None: + """Raise HTTP 400 if the request bbox falls outside the dataset's declared extents.""" + extents = dataset.get("extents") + if not extents or bbox is None: + return + spatial = extents.get("spatial") + if not spatial: + return + cov_bbox = spatial.get("bbox") + if not isinstance(cov_bbox, (list, tuple)) or len(cov_bbox) != 4: + return + cov_xmin, cov_ymin, cov_xmax, cov_ymax = cov_bbox + xmin, ymin, xmax, ymax = bbox + if ymin > cov_ymax or ymax < cov_ymin: + raise HTTPException( + status_code=400, + detail=( + f"Dataset '{dataset['id']}' does not cover this extent. " + f"Latitude coverage: {cov_ymin}°–{cov_ymax}°, " + f"requested: {ymin}°–{ymax}°." + ), + ) + if xmin > cov_xmax or xmax < cov_xmin: + raise HTTPException( + status_code=400, + detail=( + f"Dataset '{dataset['id']}' does not cover this extent. " + f"Longitude coverage: {cov_xmin}°–{cov_xmax}°, " + f"requested: {xmin}°–{xmax}°." + ), + ) + + def _get_dynamic_function(full_path: str) -> Callable[..., Any]: """Import and return a function given its dotted module path.""" parts = full_path.split(".") diff --git a/climate_api/data_registry/services/datasets.py b/climate_api/data_registry/services/datasets.py index c61e501d..0182aec6 100644 --- a/climate_api/data_registry/services/datasets.py +++ b/climate_api/data_registry/services/datasets.py @@ -23,7 +23,7 @@ def list_datasets() -> list[dict[str, Any]]: """Load all dataset templates and return a flat list. Built-in templates from climate_api/data/datasets/ are always loaded. When - datasets_dir is set in CLIMATE_API_CONFIG, templates from that directory are + templates_dir is set in CLIMATE_API_CONFIG, templates from that directory are merged on top — a custom template with the same id overrides the built-in one. CONFIGS_DIR (test override via monkeypatch) bypasses this and loads only @@ -34,16 +34,18 @@ def list_datasets() -> list[dict[str, Any]]: merged: dict[str, dict[str, Any]] = {d["id"]: d for d in _load_builtin_datasets()} - config_datasets_dir = api_config.get_config().get("datasets_dir") - if config_datasets_dir: - if not isinstance(config_datasets_dir, (str, Path)): + config_templates_dir = api_config.get_config().get("templates_dir") + if config_templates_dir: + if not isinstance(config_templates_dir, (str, Path)): raise ValueError( - f"datasets_dir in CLIMATE_API_CONFIG must be a path string, got {type(config_datasets_dir).__name__}" + f"templates_dir in CLIMATE_API_CONFIG must be a path string, got {type(config_templates_dir).__name__}" ) config_path = api_config.get_config_path() - resolved = (config_path.parent / config_datasets_dir).resolve() if config_path else Path(config_datasets_dir) - for dataset in _load_from_dir(resolved): - merged[dataset["id"]] = dataset + root = (config_path.parent / config_templates_dir).resolve() if config_path else Path(config_templates_dir) + datasets_subdir = root / "datasets" + if datasets_subdir.is_dir(): + for dataset in _load_from_dir(datasets_subdir): + merged[dataset["id"]] = dataset return list(merged.values()) diff --git a/climate_api/ingestions/services.py b/climate_api/ingestions/services.py index dfc5efd4..9f5e26e4 100644 --- a/climate_api/ingestions/services.py +++ b/climate_api/ingestions/services.py @@ -49,10 +49,11 @@ def _resolve_artifacts_dir() -> Path: - # CACHE_OVERRIDE keeps existing Docker/dev deployments working unchanged. - override = os.getenv("CACHE_OVERRIDE") - if override: - return Path(override) / "artifacts" + from climate_api import config as api_config + + data_dir = api_config.get_data_dir() + if data_dir is not None: + return data_dir / "artifacts" xdg_data = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) return xdg_data / "climate-api" / "artifacts" diff --git a/climate_api/publications/services.py b/climate_api/publications/services.py index cc531dd8..0d2d4c04 100644 --- a/climate_api/publications/services.py +++ b/climate_api/publications/services.py @@ -19,9 +19,11 @@ def _resolve_pygeoapi_dir() -> Path: - override = os.getenv("CACHE_OVERRIDE") - if override: - return Path(override) / "pygeoapi" + from climate_api import config as api_config + + data_dir = api_config.get_data_dir() + if data_dir is not None: + return data_dir / "pygeoapi" xdg_data = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local" / "share")) return xdg_data / "climate-api" / "pygeoapi" diff --git a/climate_api/stac/services.py b/climate_api/stac/services.py index 4a0c730e..7f53347a 100644 --- a/climate_api/stac/services.py +++ b/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/climate_api/system/routes.py b/climate_api/system/routes.py index e52a5911..4b9e7fe8 100644 --- a/climate_api/system/routes.py +++ b/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/climate_api/system/templates.py b/climate_api/system/templates.py index 6caa992a..6cfeca77 100644 --- a/climate_api/system/templates.py +++ b/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/climate_api/templates/landing_page.html b/climate_api/templates/landing_page.html index 49cd5999..6bd7703b 100644 --- a/climate_api/templates/landing_page.html +++ b/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