diff --git a/api/app/config/config.py b/api/app/config/config.py index a8882021..c57f6501 100644 --- a/api/app/config/config.py +++ b/api/app/config/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): auth_token: str tiff_path: str grid_tiles_path: str + tile_to_cell_resolution_diff: int = 5 @lru_cache diff --git a/api/app/routers/grid.py b/api/app/routers/grid.py index 5394220a..45a41f14 100644 --- a/api/app/routers/grid.py +++ b/api/app/routers/grid.py @@ -1,13 +1,19 @@ import logging import os import pathlib +from functools import lru_cache from typing import Annotated import h3 +import h3ronpy.polars # noqa: F401 import polars as pl +import shapely from fastapi import APIRouter, Depends, HTTPException, Path, Query +from fastapi.params import Body from fastapi.responses import ORJSONResponse +from geojson_pydantic import Feature from h3 import H3CellError +from h3ronpy.polars.vector import geometry_to_cells from pydantic import ValidationError from starlette.responses import Response @@ -19,17 +25,8 @@ grid_router = APIRouter() -@grid_router.get( - "/tile/{tile_index}", - summary="Get a grid tile", -) -async def grid_tile( - tile_index: Annotated[str, Path(description="The `h3` index of the tile")], - columns: list[str] = Query( - [], description="Colum/s to include in the tile. If empty, it returns only cell indexes." - ), -) -> Response: - """Get a tile of h3 cells with specified data columns""" +def tile_from_fs(columns, tile_index) -> tuple[pl.DataFrame, int]: + """Get the tile from filesystem filtered by column and the resolution of the tile index""" try: z = h3.api.basic_str.h3_get_resolution(tile_index) except H3CellError: @@ -38,10 +35,63 @@ async def grid_tile( if not os.path.exists(tile_path): raise HTTPException(status_code=404, detail=f"Tile {tile_path} not found") try: - tile_file = pl.read_ipc(tile_path, columns=["cell", *columns]).write_ipc(None) + tile = pl.read_ipc(tile_path, columns=["cell", *columns]) except pl.exceptions.ColumnNotFoundError: raise HTTPException(status_code=400, detail="One or more of the specified columns is not valid") from None - return Response(tile_file.getvalue(), media_type="application/octet-stream") + return tile, z + + +@grid_router.get( + "/tile/{tile_index}", + summary="Get a grid tile", +) +def get_grid_tile( + tile_index: Annotated[str, Path(description="The `h3` index of the tile")], + columns: list[str] = Query( + [], description="Colum/s to include in the tile. If empty, it returns only cell indexes." + ), +) -> Response: + """Get a tile of h3 cells with specified data columns""" + tile, _ = tile_from_fs(columns, tile_index) + tile_buffer = tile.write_ipc(None) + return Response(tile_buffer.getvalue(), media_type="application/octet-stream") + + +# @lru_cache +# def cells_in_geojson(geometry, cell_resolution: int) -> pl.Series: +# """Return the cells that fill the polygon area in the geojson""" +# cells = polyfill_geojson(geojson, cell_resolution) +# return pl.Series("shape_cells", cells, dtype=pl.UInt64) + + +@lru_cache +def cells_in_geojson(geometry, cell_resolution: int) -> pl.Series: + """Return the cells that fill the polygon area in the geojson""" + cells = geometry_to_cells(geometry, cell_resolution) + return pl.Series("shape_cells", cells, dtype=pl.UInt64) + + +@grid_router.post("/tile/{tile_index}", summary="Get a grid tile with cells contained inside the GeoJSON") +def post_grid_tile( + tile_index: Annotated[str, Path(description="The `h3` index of the tile")], + geojson: Annotated[Feature, Body(description="GeoJSON Feature.")], + columns: list[str] = Query( + [], description="Colum/s to include in the tile. If empty, it returns only cell indexes." + ), +) -> Response: + tile, tile_index_res = tile_from_fs(columns, tile_index) + cell_res = tile_index_res + get_settings().tile_to_cell_resolution_diff + geom = shapely.from_geojson(geojson.model_dump_json()) + cells = cells_in_geojson(geom, cell_res) + tile = ( + tile.with_columns(pl.col("cell").h3.cells_parse()) + .filter(pl.col("cell").is_in(cells)) + .with_columns(pl.col("cell").h3.cells_to_string()) + ) + if tile.is_empty(): + raise HTTPException(status_code=404, detail="No data in region") + tile_buffer = tile.write_ipc(None) + return Response(tile_buffer.getvalue(), media_type="application/octet-stream") @grid_router.get( diff --git a/api/requirements.in b/api/requirements.in index a2bf54c0..2d6a81c2 100644 --- a/api/requirements.in +++ b/api/requirements.in @@ -8,3 +8,4 @@ h3 pydantic-extra-types polars sqlalchemy +h3ronpy \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt index 50c7a96b..135b3189 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -34,8 +34,11 @@ cligj==0.7.2 color-operations==0.1.3 # via rio-tiler exactextract==0.2.0.dev0 + # via -r requirements.in fastapi==0.110.1 - # via titiler-core + # via + # -r requirements.in + # titiler-core geojson-pydantic==1.0.2 # via titiler-core greenlet==3.0.3 @@ -45,6 +48,9 @@ h11==0.14.0 # httpcore # uvicorn h3==3.7.7 + # via -r requirements.in +h3ronpy==0.21.0 + # via -r requirements.in httpcore==1.0.5 # via httpx httpx==0.27.0 @@ -66,13 +72,20 @@ numexpr==2.10.0 numpy==1.26.4 # via # color-operations + # h3ronpy # numexpr + # pyarrow # rasterio # rio-tiler + # shapely # snuggs # titiler-core orjson==3.10.0 + # via -r requirements.in polars==1.1.0 + # via -r requirements.in +pyarrow==17.0.0 + # via h3ronpy pydantic==2.6.4 # via # fastapi @@ -85,7 +98,9 @@ pydantic==2.6.4 pydantic-core==2.16.3 # via pydantic pydantic-extra-types==2.9.0 + # via -r requirements.in pydantic-settings==2.2.1 + # via -r requirements.in pyparsing==3.1.2 # via snuggs pyproj==3.6.1 @@ -104,6 +119,8 @@ rio-tiler==6.4.5 # via titiler-core setuptools==69.2.0 # via rasterio +shapely==2.0.6 + # via h3ronpy simplejson==3.19.2 # via titiler-core six==1.16.0 @@ -115,9 +132,11 @@ sniffio==1.3.1 snuggs==1.4.7 # via rasterio sqlalchemy==2.0.31 + # via -r requirements.in starlette==0.37.2 # via fastapi titiler-core==0.18.0 + # via -r requirements.in typing-extensions==4.11.0 # via # fastapi @@ -126,3 +145,4 @@ typing-extensions==4.11.0 # sqlalchemy # titiler-core uvicorn==0.29.0 + # via -r requirements.in