Source code for earthkit_data_meteoswiss_opendata.api
"""
MeteoSwiss Open Data STAC API helpers.
Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
Distributed under the terms of the BSD 3-Clause License.
SPDX-License-Identifier: BSD-3-Clause
"""
import datetime as dt
import re
from typing import Any
from urllib.parse import urlparse
import requests
from .request import Request
API_URL = "https://data.geo.admin.ch/api/stac/v1"
TIMEOUT = 30
session = requests.Session()
def _search(
url: str,
body: dict[str, Any],
) -> list[str]:
"""Execute a STAC search and collect asset URLs from all result pages.
The STAC response contains matching items in ``features``. Each item
contains an ``assets`` mapping whose ``href`` values point to the
downloadable files.
When more results are available, the response contains a link with
``rel="next"`` in ``links``. Its request parameters are merged with
the current search body and used to retrieve the next page.
Args:
url: STAC search endpoint or next-page URL.
body: JSON-compatible STAC search body.
Returns:
Asset URLs from the current page and any following pages.
Raises:
requests.HTTPError: If the STAC request fails.
RuntimeError: If an unsupported pagination link is returned.
See Also:
`STAC item search API
<https://data.geo.admin.ch/api/stac/static/spec/v1/api.html#tag/STAC/operation/postSearchSTAC>`_
"""
response = session.post(
url,
json=body,
timeout=TIMEOUT,
)
response.raise_for_status()
payload = response.json()
result: list[str] = []
# Each feature is a matching STAC item. Its assets contain
# the downloadable file URLs.
for feature in payload.get("features", []):
for asset in feature.get("assets", {}).values():
result.append(asset["href"])
# A link with rel="next" indicates another page of results.
for link in payload.get("links", []):
if link.get("rel") != "next":
continue
# The API describes the next page as another POST request
# whose body must be merged with the current search body.
if link.get("method") != "POST" or not link.get("merge"):
raise RuntimeError(f"Unsupported STAC pagination link: {link}")
# Keep the search filters and add the next-page parameters.
next_body = body | link.get("body", {})
# Retrieve the next page and append its asset URLs.
result.extend(
_search(
link["href"],
next_body,
)
)
return result
[docs]
def get_asset_urls(request: Request) -> list[str]:
"""Return assets matching a MeteoSwiss request.
For requests with several lead times, only complete
forecast runs are returned.
"""
# Convert the validated request into a STAC search.
urls = _search(
f"{API_URL}/search",
request.dump(),
)
if not urls:
raise ValueError("No assets matched the request")
# No run selection is needed for one requested and returned asset.
if len(urls) == 1 and len(request.lead_times) == 1:
return urls
# Extract reference time and lead time from OGD asset filenames.
pattern = re.compile(
r"-(?P<ref_time>\d{12})-"
r"(?P<lead_time>\d+)-"
)
def extract_key(
url: str,
) -> tuple[dt.datetime, dt.timedelta]:
"""Return the forecast run and lead time encoded in a URL."""
# Parse only the URL path, excluding query parameters.
path = urlparse(url).path
match = pattern.search(path)
if match is None:
raise ValueError(f"No valid forecast datetime found in URL: {url}")
# Convert YYYYMMDDHHMM into a timezone-aware UTC datetime.
ref_time = dt.datetime.strptime(
match.group("ref_time"),
"%Y%m%d%H%M",
).replace(tzinfo=dt.timezone.utc)
# Convert the encoded forecast hour into a timedelta.
lead_time = dt.timedelta(hours=float(match.group("lead_time")))
return ref_time, lead_time
# Index each asset by forecast run and lead time.
asset_map = {extract_key(url): url for url in urls}
# Record which lead times are available for each forecast run.
available: dict[
dt.datetime,
set[dt.timedelta],
] = {}
for ref_time, lead_time in asset_map:
available.setdefault(
ref_time,
set(),
).add(lead_time)
# Lead times requested by the user.
required = set(request.lead_times)
# Keep only runs containing every requested lead time.
complete_runs = sorted(ref_time for ref_time, lead_times in available.items() if lead_times >= required)
if not complete_runs:
raise ValueError("No complete forecast run contains all requested lead times")
if request.reference_datetime == "latest":
# Select the newest complete forecast run.
latest = complete_runs[-1]
# Preserve the lead-time order requested by the user.
return [asset_map[(latest, lead_time)] for lead_time in request.lead_times]
# For an explicit time range, return every complete run,
# ordered first by requested lead time and then by run time.
return [asset_map[(ref_time, lead_time)] for lead_time in request.lead_times for ref_time in complete_runs]
[docs]
def get_collection_asset_url(
collection_id: str,
asset_id: str,
) -> str:
"""Return the URL of a collection-level static asset.
Args:
collection_id: Fully qualified STAC collection ID.
asset_id: Identifier of the collection asset.
Returns:
URL of the requested collection asset.
Raises:
requests.HTTPError: If the collection-assets request fails.
KeyError: If the requested asset is not present.
See Also:
`STAC collection-assets API
<https://data.geo.admin.ch/api/stac/static/spec/v1/api.html#tag/Data/operation/getCollectionAssets>`_
"""
response = session.get(
f"{API_URL}/collections/{collection_id}/assets",
timeout=TIMEOUT,
)
response.raise_for_status()
# Collection assets are returned as a list of metadata objects.
for asset in response.json().get("assets", []):
if asset.get("id") == asset_id:
return str(asset["href"])
raise KeyError(f"Asset {asset_id!r} was not found in collection {collection_id!r}")