From cbb4c854035a0a725d44ffeeec5de3838fb4d0e6 Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Fri, 14 Nov 2025 20:57:37 +0100 Subject: [PATCH 1/4] allow per selector method --- src/titiler/xarray/tests/test_dependencies.py | 9 ++++- src/titiler/xarray/tests/test_factory.py | 3 +- src/titiler/xarray/tests/test_io_tools.py | 15 ++++--- .../xarray/titiler/xarray/dependencies.py | 29 +++++--------- src/titiler/xarray/titiler/xarray/io.py | 40 ++++++++++++++----- 5 files changed, 54 insertions(+), 42 deletions(-) diff --git a/src/titiler/xarray/tests/test_dependencies.py b/src/titiler/xarray/tests/test_dependencies.py index 1453860c9..80700e6b2 100644 --- a/src/titiler/xarray/tests/test_dependencies.py +++ b/src/titiler/xarray/tests/test_dependencies.py @@ -71,6 +71,11 @@ def tiles( response = client.get("/tiles/1/2/3", params={"sel": "yo="}) assert response.status_code == 422 - response = client.get("/tiles/1/2/3", params={"sel_method": "nearest"}) + response = client.get("/tiles/1/2/3", params={"sel": "time=near::2023-01-01"}) + assert response.status_code == 422 + + response = client.get( + "/tiles/1/2/3", params={"sel": ["yo=nearest::yo", "ye=ye"]} + ) params = response.json() - assert params == {"method": "nearest"} + assert params == {"sel": ["yo=nearest::yo", "ye=ye"]} diff --git a/src/titiler/xarray/tests/test_factory.py b/src/titiler/xarray/tests/test_factory.py index d760cbe7a..a09e38439 100644 --- a/src/titiler/xarray/tests/test_factory.py +++ b/src/titiler/xarray/tests/test_factory.py @@ -175,8 +175,7 @@ def test_info_da_options(app): params={ "url": dataset_4d_nc, "variable": "dataset", - "sel": "z=1", - "sel_method": "nearest", + "sel": "z=nearest::1", }, ) assert resp.status_code == 200 diff --git a/src/titiler/xarray/tests/test_io_tools.py b/src/titiler/xarray/tests/test_io_tools.py index bc0ae063e..597e9184d 100644 --- a/src/titiler/xarray/tests/test_io_tools.py +++ b/src/titiler/xarray/tests/test_io_tools.py @@ -53,8 +53,7 @@ def test_get_variable(): da = get_variable( ds, "dataset", - sel=["time=2022-12-01", "time=2023-01-01"], - method="nearest", + sel=["time=nearest::2022-12-01", "time=nearest::2023-01-01"], ) assert da.rio.crs assert da.dims == ("time", "y", "x") @@ -70,7 +69,7 @@ def test_get_variable(): assert da["time"][1] == numpy.datetime64("2023-01-01") # Select the Nearest Time - da = get_variable(ds, "dataset", sel=["time=2024-01-01T01:00:00"], method="nearest") + da = get_variable(ds, "dataset", sel=["time=nearest::2024-01-01T01:00:00"]) assert da.rio.crs assert da.dims == ("y", "x") assert da["time"] == numpy.datetime64("2023-01-01") @@ -186,20 +185,20 @@ def test_get_variable_datetime_tz(): assert data.dims == ("time", "y", "x") ds = data.to_dataset(name="dataset") - da = get_variable(ds, "dataset", sel=["time=2023-01-01T00:00:00"], method="nearest") + da = get_variable(ds, "dataset", sel=["time=nearest::2023-01-01T00:00:00"]) assert da.rio.crs assert da.dims == ("y", "x") assert da["time"] == numpy.datetime64("2023-01-01") - da = get_variable( - ds, "dataset", sel=["time=2023-01-01T00:00:00Z"], method="nearest" - ) + da = get_variable(ds, "dataset", sel=["time=nearest::2023-01-01T00:00:00Z"]) assert da.rio.crs assert da.dims == ("y", "x") assert da["time"] == numpy.datetime64("2023-01-01") da = get_variable( - ds, "dataset", sel=["time=2023-01-01T00:00:00+03:00"], method="nearest" + ds, + "dataset", + sel=["time=nearest::2023-01-01T00:00:00+03:00"], ) assert da.rio.crs assert da.dims == ("y", "x") diff --git a/src/titiler/xarray/titiler/xarray/dependencies.py b/src/titiler/xarray/titiler/xarray/dependencies.py index 71483bf0d..1a3722641 100644 --- a/src/titiler/xarray/titiler/xarray/dependencies.py +++ b/src/titiler/xarray/titiler/xarray/dependencies.py @@ -1,7 +1,7 @@ """titiler.xarray dependencies.""" from dataclasses import dataclass -from typing import List, Literal, Optional, Union +from typing import List, Optional, Union import numpy from fastapi import Query @@ -33,7 +33,12 @@ class XarrayIOParams(DefaultDependency): ] = None -SelDimStr = Annotated[str, StringConstraints(pattern=r"^[^=]+=[^=]+$")] +SelDimStr = Annotated[ + str, + StringConstraints( + pattern=r"^[^=]+=((nearest|pad|ffill|backfill|bfill)::)?[^=::]+$" + ), +] @dataclass @@ -45,15 +50,7 @@ class XarrayDsParams(DefaultDependency): sel: Annotated[ Optional[List[SelDimStr]], Query( - description="Xarray Indexing using dimension names `{dimension}={value}`.", - ), - ] = None - - method: Annotated[ - Optional[Literal["nearest", "pad", "ffill", "backfill", "bfill"]], - Query( - alias="sel_method", - description="Xarray indexing method to use for inexact matches.", + description="Xarray Indexing using dimension names `{dimension}={value}` or `{dimension}={method}::{value}`.", ), ] = None @@ -81,15 +78,7 @@ class CompatXarrayParams(XarrayIOParams): sel: Annotated[ Optional[List[SelDimStr]], Query( - description="Xarray Indexing using dimension names `{dimension}={value}`.", - ), - ] = None - - method: Annotated[ - Optional[Literal["nearest", "pad", "ffill", "backfill", "bfill"]], - Query( - alias="sel_method", - description="Xarray indexing method to use for inexact matches.", + description="Xarray Indexing using dimension names `{dimension}={value}` or `{dimension}={method}::{value}`.", ), ] = None diff --git a/src/titiler/xarray/titiler/xarray/io.py b/src/titiler/xarray/titiler/xarray/io.py index ebbfe3476..d699c25d6 100644 --- a/src/titiler/xarray/titiler/xarray/io.py +++ b/src/titiler/xarray/titiler/xarray/io.py @@ -149,7 +149,7 @@ def get_variable( ds: xarray.Dataset, variable: str, sel: Optional[List[str]] = None, - method: Optional[Literal["nearest", "pad", "ffill", "backfill", "bfill"]] = None, + # method: Optional[Literal["nearest", "pad", "ffill", "backfill", "bfill"]] = None, ) -> xarray.DataArray: """Get Xarray variable as DataArray. @@ -166,22 +166,43 @@ def get_variable( da = ds[variable] if sel: + # Handle mutiple `sel` with same dimension + # e.g sel=["time=2020-01-01", "time=2020-02-01", "band=1"] _idx: Dict[str, List] = {} - for s in sel: + for selector in sel: val: Union[str, slice] - dim, val = s.split("=") - - # cast string to dtype of the dimension - if da[dim].dtype != "O": - val = da[dim].dtype.type(val) + dim, val = selector.split("=") if dim in _idx: _idx[dim].append(val) else: _idx[dim] = [val] - sel_idx = {k: v[0] if len(v) < 2 else v for k, v in _idx.items()} - da = da.sel(sel_idx, method=method) + # Loop through all dimension=values selectors + # - parse method::value if provided + # - check if multiple methods are provided for the same dimension + # - cast values to the dimension dtype + # - apply the selection + for dimension, values in _idx.items(): + methods, values = zip( # type: ignore + *[v.split("::", 1) if "::" in v else (None, v) for v in values] + ) + method_sets = {m for m in methods if m is not None} + if len(method_sets) > 1: + raise ValueError( + f"Multiple selection methods provided for dimension {dimension}: {methods}" + ) + method = method_sets.pop() if method_sets else None + + # TODO: add more casting + # cast string to dtype of the dimension + if da[dimension].dtype != "O": + values = [da[dimension].dtype.type(v) for v in values] + + da = da.sel( + {dimension: values[0] if len(values) < 2 else values}, + method=method, + ) da = _arrange_dims(da) @@ -241,7 +262,6 @@ def __attrs_post_init__(self): self.ds, self.variable, sel=self.sel, - method=self.method, ) super().__attrs_post_init__() From cb8c625fe98914a7089491ef3c408d0fb836267d Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 18 Nov 2025 22:22:40 +0100 Subject: [PATCH 2/4] add zarr notebook --- docs/mkdocs.yml | 1 + docs/src/advanced/dependencies.md | 13 +- .../notebooks/Working_with_Zarr.ipynb | 279 ++++++++++++++++++ 3 files changed, 281 insertions(+), 12 deletions(-) create mode 100644 docs/src/examples/notebooks/Working_with_Zarr.ipynb diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 3c5d0ad38..75ba1502e 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -93,6 +93,7 @@ nav: - NumpyTile: "examples/notebooks/Working_with_NumpyTile.ipynb" - Algorithm: "examples/notebooks/Working_with_Algorithm.ipynb" - Statistics: "examples/notebooks/Working_with_Statistics.ipynb" + - Xarray: "examples/notebooks/Working_with_Zarr.ipynb" - API: - titiler.core: diff --git a/docs/src/advanced/dependencies.md b/docs/src/advanced/dependencies.md index 094c7db90..566b7a466 100644 --- a/docs/src/advanced/dependencies.md +++ b/docs/src/advanced/dependencies.md @@ -1018,7 +1018,6 @@ Define options to select a **variable** within a Xarray Dataset. | ------ | ---------- |----------|-------------- | **variable** | Query (str) | Yes | None | **sel** | Query (list of str) | No | None -| **method** | Query (str)| No | None
@@ -1032,15 +1031,7 @@ class XarrayDsParams(DefaultDependency): sel: Annotated[ Optional[List[SelDimStr]], Query( - description="Xarray Indexing using dimension names `{dimension}={value}`.", - ), - ] = None - - method: Annotated[ - Optional[Literal["nearest", "pad", "ffill", "backfill", "bfill"]], - Query( - alias="sel_method", - description="Xarray indexing method to use for inexact matches.", + description="Xarray Indexing using dimension names `{dimension}={value}` or `{dimension}={method}::{value}`.", ), ] = None ``` @@ -1058,7 +1049,6 @@ Combination of `XarrayIOParams` and `XarrayDsParams` | **decode_times** | Query (bool)| No | None | **variable** | Query (str) | Yes | None | **sel** | Query (list of str) | No | None -| **method** | Query (str)| No | None
@@ -1082,7 +1072,6 @@ same as `XarrayParams` but with optional `variable` option. | **decode_times** | Query (bool)| No | None | **variable** | Query (str) | No | None | **sel** | Query (list of str) | No | None -| **method** | Query (str)| No | None
diff --git a/docs/src/examples/notebooks/Working_with_Zarr.ipynb b/docs/src/examples/notebooks/Working_with_Zarr.ipynb new file mode 100644 index 000000000..f1939df86 --- /dev/null +++ b/docs/src/examples/notebooks/Working_with_Zarr.ipynb @@ -0,0 +1,279 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "collapsed": false + }, + "source": [ + "# Working with Zarr" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": false + }, + "source": [ + "## Intro\n", + "\n", + "`titiler.xarray` is a submodule designed specifically for working with multidimensional dataset. With version `0.25.0`, we've introduced a default application with only support for Zarr dataset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2023-04-06T14:25:40.161502Z", + "start_time": "2023-04-06T14:25:40.153667Z" + }, + "collapsed": false + }, + "outputs": [], + "source": [ + "# setup\n", + "import httpx\n", + "import json\n", + "from IPython.display import Image\n", + "\n", + "# Developmentseed Demo endpoint. Please be kind. Ref: https://github.com/developmentseed/titiler/discussions/1223\n", + "# titiler_endpoint = \"https://xarray.titiler.xyz\"\n", + "\n", + "# Or launch your own local instance with:\n", + "# uv run --group server uvicorn titiler.xarray.main:app --host 127.0.0.1 --port 8080 --reload\n", + "titiler_endpoint = \"http://127.0.0.1:8080\"\n", + "\n", + "zarr_url = \"https://nasa-power.s3.us-west-2.amazonaws.com/syn1deg/temporal/power_syn1deg_monthly_temporal_lst.zarr\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "collapsed": false + }, + "source": [ + "## Dataset Metadata\n", + "\n", + "The `/dataset/dict` endpoint returns general metadata about the Zarr Dataset\n", + "\n", + "Endpoint: `/dataset/dict`\n", + "\n", + "QueryParams:\n", + "- **url**: Zarr store URL" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "ExecuteTime": { + "end_time": "2023-04-06T14:25:42.410135Z", + "start_time": "2023-04-06T14:25:42.355858Z" + }, + "collapsed": false + }, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/dataset/dict\",\n", + " params={\n", + " \"url\": zarr_url,\n", + " },\n", + ").json()\n", + "\n", + "print(json.dumps(r, indent=2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### List of available variables\n", + "\n", + "Endpoint: `/dataset/keys`\n", + "\n", + "QueryParams:\n", + "- **url**: Zarr store URL" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/dataset/keys\",\n", + " params={\n", + " \"url\": zarr_url,\n", + " },\n", + ").json()\n", + "\n", + "print(json.dumps(r, indent=2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Variable Info\n", + "\n", + "We can use `/info` endpoint to get more `Geo` information about a specific variable.\n", + "\n", + "QueryParams:\n", + "- **url**: Zarr store URL\n", + "- **variable**: Variable's name (e.g `AIRMASS`, found in `/dataset/keys` response)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/info\",\n", + " params={\"url\": zarr_url, \"variable\": \"AIRMASS\"},\n", + ").json()\n", + "\n", + "print(json.dumps(r, indent=2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Or as a GeoJSON feature" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/info.geojson\",\n", + " params={\"url\": zarr_url, \"variable\": \"AIRMASS\"},\n", + ").json()\n", + "\n", + "print(json.dumps(r, indent=2))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Knowledge\n", + "\n", + "Looking at the `info` response we can see that the `AIRMASS` variable has `348` (count) bands, each one corresponding to as specific `TIME` (day).\n", + "\n", + "We can also see that the data is stored as `float32` which mean that we will have to apply linear rescaling in order to get output image as PNG/JPEG.\n", + "\n", + "The `min/max` values are also indicated with `valid_max=31.73` and `valid_min=1.0`.\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Dimension Reduction\n", + "\n", + "We cannot visualize all the `bands` at once, so we need to perform dimension reduction to go from array in shape (348, 360, 180) to a 1b (1, 360, 180) or 3b (3, 360, 180) image. \n", + "\n", + "To do it, we have two methods whitin `titiler.xarray`:\n", + "- using `bidx=`: same as for COG we can select a band index\n", + "- using `sel={dimension}=value`: which will be using xarray `.sel` method" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/bbox/-180,-90,180,90.png\",\n", + " params=(\n", + " (\"url\", zarr_url),\n", + " (\"variable\", \"AIRMASS\"),\n", + " # Select 1 specific band\n", + " (\"bidx\", 50),\n", + " (\"rescale\", \"1,20\"),\n", + " (\"colormap_name\", \"viridis\"),\n", + " ),\n", + " timeout=10,\n", + ")\n", + "\n", + "Image(r.content)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/bbox/-180,-90,180,90.png\",\n", + " params=(\n", + " (\"url\", zarr_url),\n", + " (\"variable\", \"AIRMASS\"),\n", + " # Select 1 specific time slices\n", + " (\"sel\", \"time=2003-06-30\"),\n", + " (\"rescale\", \"1,20\"),\n", + " (\"colormap_name\", \"viridis\"),\n", + " ),\n", + " timeout=10,\n", + ")\n", + "\n", + "Image(r.content)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "r = httpx.get(\n", + " f\"{titiler_endpoint}/bbox/-180,-90,180,90.png\",\n", + " params=(\n", + " (\"url\", zarr_url),\n", + " (\"variable\", \"AIRMASS\"),\n", + " # Select 3 specific time slices to create a 3 band image\n", + " (\"sel\", \"time=2003-06-30\"),\n", + " (\"sel\", \"time=2004-06-30\"),\n", + " (\"sel\", \"time=2005-06-30\"),\n", + " (\"rescale\", \"1,10\"),\n", + " ),\n", + " timeout=10,\n", + ")\n", + "\n", + "Image(r.content)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "python3.13 (3.13.7)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.7" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} From 380c348ab73d41c92bef24a1fd86cecbc60de9ef Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Tue, 18 Nov 2025 23:25:17 +0100 Subject: [PATCH 3/4] update changelog --- CHANGES.md | 14 ++++++++++++++ src/titiler/xarray/titiler/xarray/io.py | 1 - 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 10dbb5e4e..8317bf5e7 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,19 @@ # Release Notes +## Unreleased + +### titiler.xarray + +* use `sel={dim}={method}::{value}` notation to specify selector method instead of `sel-method` query-parameter **breaking change** + + ```python + # before + .../info?tore.zarr?sel=time=2023-01-01&sel_method=nearest` + + # now + .../info?tore.zarr?sel=time=nearest::2023-01-01` + ``` + ## 0.25.0 (2025-11-07) ### Misc diff --git a/src/titiler/xarray/titiler/xarray/io.py b/src/titiler/xarray/titiler/xarray/io.py index d699c25d6..076192f4d 100644 --- a/src/titiler/xarray/titiler/xarray/io.py +++ b/src/titiler/xarray/titiler/xarray/io.py @@ -149,7 +149,6 @@ def get_variable( ds: xarray.Dataset, variable: str, sel: Optional[List[str]] = None, - # method: Optional[Literal["nearest", "pad", "ffill", "backfill", "bfill"]] = None, ) -> xarray.DataArray: """Get Xarray variable as DataArray. From ff09112188e151131e85d1099f7141feaad2a60b Mon Sep 17 00:00:00 2001 From: vincentsarago Date: Wed, 19 Nov 2025 11:59:59 +0100 Subject: [PATCH 4/4] create parse_dsl function and improve tests --- src/titiler/xarray/tests/test_dependencies.py | 50 +++----- src/titiler/xarray/tests/test_io_tools.py | 55 ++++++++- src/titiler/xarray/titiler/xarray/io.py | 109 ++++++++++++------ 3 files changed, 140 insertions(+), 74 deletions(-) diff --git a/src/titiler/xarray/tests/test_dependencies.py b/src/titiler/xarray/tests/test_dependencies.py index 80700e6b2..01fd73d8a 100644 --- a/src/titiler/xarray/tests/test_dependencies.py +++ b/src/titiler/xarray/tests/test_dependencies.py @@ -1,8 +1,6 @@ """test dependencies.""" -from typing import Annotated - -from fastapi import Depends, FastAPI, Path +from fastapi import Depends, FastAPI from starlette.testclient import TestClient from titiler.xarray import dependencies @@ -12,70 +10,50 @@ def test_xarray_tile(): """Create App.""" app = FastAPI() - @app.get("/tiles/{z}/{x}/{y}") - def tiles( - z: Annotated[ - int, - Path( - description="Identifier (Z) selecting one of the scales defined in the TileMatrixSet and representing the scaleDenominator the tile.", - ), - ], - x: Annotated[ - int, - Path( - description="Column (X) index of the tile on the selected TileMatrix. It cannot exceed the MatrixHeight-1 for the selected TileMatrix.", - ), - ], - y: Annotated[ - int, - Path( - description="Row (Y) index of the tile on the selected TileMatrix. It cannot exceed the MatrixWidth-1 for the selected TileMatrix.", - ), - ], + @app.get("/") + def endpoint( params=Depends(dependencies.CompatXarrayParams), ): """return params.""" return params.as_dict() with TestClient(app) as client: - response = client.get("/tiles/1/2/3") + response = client.get("/") params = response.json() assert params == {} - response = client.get("/tiles/1/2/3", params={"variable": "yo"}) + response = client.get("/", params={"variable": "yo"}) params = response.json() assert params == {"variable": "yo"} - response = client.get("/tiles/1/2/3", params={"sel": "yo=yo"}) + response = client.get("/", params={"sel": "yo=yo"}) params = response.json() assert params == {"sel": ["yo=yo"]} - response = client.get("/tiles/1/2/3", params={"sel": "yo=1.0"}) + response = client.get("/", params={"sel": "yo=1.0"}) params = response.json() assert params == {"sel": ["yo=1.0"]} - response = client.get("/tiles/1/2/3", params={"sel": ["yo=yo", "ye=ye"]}) + response = client.get("/", params={"sel": ["yo=yo", "ye=ye"]}) params = response.json() assert params == {"sel": ["yo=yo", "ye=ye"]} - response = client.get("/tiles/1/2/3?sel=yo=yo&sel=ye=ye") + response = client.get("/?sel=yo=yo&sel=ye=ye") params = response.json() assert params == {"sel": ["yo=yo", "ye=ye"]} - response = client.get("/tiles/1/2/3", params={"sel": "yo"}) + response = client.get("/", params={"sel": "yo"}) assert response.status_code == 422 - response = client.get("/tiles/1/2/3", params={"sel": "=yo"}) + response = client.get("/", params={"sel": "=yo"}) assert response.status_code == 422 - response = client.get("/tiles/1/2/3", params={"sel": "yo="}) + response = client.get("/", params={"sel": "yo="}) assert response.status_code == 422 - response = client.get("/tiles/1/2/3", params={"sel": "time=near::2023-01-01"}) + response = client.get("/", params={"sel": "time=near::2023-01-01"}) assert response.status_code == 422 - response = client.get( - "/tiles/1/2/3", params={"sel": ["yo=nearest::yo", "ye=ye"]} - ) + response = client.get("/", params={"sel": ["yo=nearest::yo", "ye=ye"]}) params = response.json() assert params == {"sel": ["yo=nearest::yo", "ye=ye"]} diff --git a/src/titiler/xarray/tests/test_io_tools.py b/src/titiler/xarray/tests/test_io_tools.py index 597e9184d..24d98fbf6 100644 --- a/src/titiler/xarray/tests/test_io_tools.py +++ b/src/titiler/xarray/tests/test_io_tools.py @@ -9,7 +9,13 @@ import pytest import xarray -from titiler.xarray.io import Reader, fs_open_dataset, get_variable, open_zarr +from titiler.xarray.io import ( + Reader, + _parse_dsl, + fs_open_dataset, + get_variable, + open_zarr, +) prefix = os.path.join(os.path.dirname(__file__), "fixtures") @@ -345,3 +351,50 @@ def test_io_open_zarr(src_path, options): """test open_zarr with cloud hosted files.""" with open_zarr(src_path, **options) as ds: assert list(ds.data_vars) + + +@pytest.mark.parametrize( + "sel,expected", + [ + ( + ["time=2022-01-01", "level=10"], + [ + {"dimension": "time", "values": ["2022-01-01"], "method": None}, + {"dimension": "level", "values": ["10"], "method": None}, + ], + ), + ( + ["time=2022-01-01", "time=2022-01-02"], + [ + { + "dimension": "time", + "values": ["2022-01-01", "2022-01-02"], + "method": None, + }, + ], + ), + ( + ["time=pad::2022-01-01", "time=2022-01-02", "level=nearest::10"], + [ + { + "dimension": "time", + "values": ["2022-01-01", "2022-01-02"], + "method": "pad", + }, + {"dimension": "level", "values": ["10"], "method": "nearest"}, + ], + ), + ([], []), + ], +) +def test_parse_dsl(sel, expected): + """test _parse_dsl function.""" + result = _parse_dsl(sel) + assert result == expected + + +def test_parse_dsl_invalid(): + """Should raise a ValueError when multiple methods are set for a dimension.""" + sel = ["time=pad::2022-01-01", "time=nearest::2022-01-02"] + with pytest.raises(ValueError): + _parse_dsl(sel) diff --git a/src/titiler/xarray/titiler/xarray/io.py b/src/titiler/xarray/titiler/xarray/io.py index b1e52da4b..4ffd26e08 100644 --- a/src/titiler/xarray/titiler/xarray/io.py +++ b/src/titiler/xarray/titiler/xarray/io.py @@ -17,6 +17,7 @@ from morecantile import TileMatrixSet from rio_tiler.constants import WEB_MERCATOR_TMS from rio_tiler.io.xarray import XarrayReader +from typing_extensions import TypedDict from zarr.storage import ObjectStore @@ -145,6 +146,64 @@ def _arrange_dims(da: xarray.DataArray) -> xarray.DataArray: return da +class selector(TypedDict): + """STAC Item.""" + + dimension: str + values: list[Any] + method: Literal["nearest", "pad", "ffill", "backfill", "bfill"] | None + + +def _parse_dsl(sel: list[str] | None) -> list[selector]: + """Parse sel DSL into dictionary. + + Args: + sel (list of str, optional): List of Xarray Indexes. + + Returns: + list: list of dimension/values/method. + + """ + sel = sel or [] + + _idx: Dict[str, List] = {} + for s in sel: + val: Union[str, slice] + dim, val = s.split("=") + + if dim in _idx: + _idx[dim].append(val) + else: + _idx[dim] = [val] + + # Loop through all dimension=values selectors + # - parse method::value if provided + # - check if multiple methods are provided for the same dimension + # - cast values to the dimension dtype + # - apply the selection + selectors: list[selector] = [] + for dimension, values in _idx.items(): + methods, values = zip( # type: ignore + *[v.split("::", 1) if "::" in v else (None, v) for v in values] + ) + method_sets = {m for m in methods if m is not None} + if len(method_sets) > 1: + raise ValueError( + f"Multiple selection methods provided for dimension {dimension}: {methods}" + ) + method = method_sets.pop() if method_sets else None + + selectors.append( + { + "dimension": dimension, + "values": list(values), + "method": method, + } + ) + + return selectors + + def get_variable( ds: xarray.Dataset, variable: str, @@ -164,44 +223,20 @@ def get_variable( """ da = ds[variable] - if sel: - # Handle mutiple `sel` with same dimension - # e.g sel=["time=2020-01-01", "time=2020-02-01", "band=1"] - _idx: Dict[str, List] = {} - for selector in sel: - val: Union[str, slice] - dim, val = selector.split("=") + for selector in _parse_dsl(sel): + dimension = selector["dimension"] + values = selector["values"] + method = selector["method"] - if dim in _idx: - _idx[dim].append(val) - else: - _idx[dim] = [val] - - # Loop through all dimension=values selectors - # - parse method::value if provided - # - check if multiple methods are provided for the same dimension - # - cast values to the dimension dtype - # - apply the selection - for dimension, values in _idx.items(): - methods, values = zip( # type: ignore - *[v.split("::", 1) if "::" in v else (None, v) for v in values] - ) - method_sets = {m for m in methods if m is not None} - if len(method_sets) > 1: - raise ValueError( - f"Multiple selection methods provided for dimension {dimension}: {methods}" - ) - method = method_sets.pop() if method_sets else None - - # TODO: add more casting - # cast string to dtype of the dimension - if da[dimension].dtype != "O": - values = [da[dimension].dtype.type(v) for v in values] - - da = da.sel( - {dimension: values[0] if len(values) < 2 else values}, - method=method, - ) + # TODO: add more casting + # cast string to dtype of the dimension + if da[dimension].dtype != "O": + values = [da[dimension].dtype.type(v) for v in values] + + da = da.sel( + {dimension: values[0] if len(values) < 2 else values}, + method=method, + ) da = _arrange_dims(da)