Skip to content
This repository was archived by the owner on Dec 22, 2025. It is now read-only.

Commit 4ba4df9

Browse files
authored
Merge pull request #49 from UW-Macrostrat/ingestion-fixes
Map ingestion fixes
2 parents 80e82ed + cfe2451 commit 4ba4df9

File tree

6 files changed

+283
-18
lines changed

6 files changed

+283
-18
lines changed

.idea/tileserver.iml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ ENV PIP_DISABLE_PIP_VERSION_CHECK=1 POETRY_VIRTUALENVS_CREATE=false
1313

1414
RUN python3 -m venv /poetry-env
1515
RUN /poetry-env/bin/pip install -U pip setuptools
16-
RUN /poetry-env/bin/pip install poetry
16+
RUN /poetry-env/bin/pip install poetry==1.8.4
1717

1818

1919
WORKDIR /app/

macrostrat_tileserver/main.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
connect_to_db,
1515
register_table_catalog,
1616
)
17-
from timvt.settings import PostgresSettings
1817
from timvt.layer import FunctionRegistry
1918
from titiler.core.errors import DEFAULT_STATUS_CODES, add_exception_handlers
2019
from titiler.core.factory import TilerFactory
@@ -30,6 +29,7 @@
3029
from pathlib import Path
3130
from time import time
3231

32+
from .map_ingestion import register_map_ingestion_routes
3333

3434
from typing import Any, Optional
3535
from buildpg import asyncpg
@@ -100,9 +100,8 @@ async def startup_event():
100100

101101
# Apply fixtures
102102
# apply_fixtures(db_settings.database_url)
103-
await register_table_catalog(app, schemas=["sources"])
103+
# await register_table_catalog(app, schemas=["sources"])
104104
prepare_image_tile_subsystem()
105-
print("Application started.")
106105

107106

108107
@app.on_event("startup")
@@ -138,6 +137,10 @@ async def shutdown_event():
138137

139138
app.add_middleware(CompressionMiddleware, minimum_size=0)
140139

140+
# Map ingestion
141+
register_map_ingestion_routes(app)
142+
143+
141144
MapnikLayerFactory(app)
142145

143146
cog = TilerFactory()
@@ -148,8 +151,8 @@ async def shutdown_event():
148151

149152
# Register endpoints.
150153
mvt_tiler = CachedVectorTilerFactory(
151-
with_tables_metadata=True,
152-
with_functions_metadata=True, # add Functions metadata endpoints (/functions.json, /{function_name}.json)
154+
with_tables_metadata=False,
155+
with_functions_metadata=False, # add Functions metadata endpoints (/functions.json, /{function_name}.json)
153156
with_viewer=False,
154157
)
155158

macrostrat_tileserver/map_bounds/__init__.py

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,33 @@ async def rgeom(
1717
y: int,
1818
):
1919
"""Get a tile from the tileserver."""
20-
pool = request.app.state.pool
20+
return await get_rgeom(request.app.state.pool, z=z, x=x, y=y)
2121

22+
23+
@router.get("/bounds/{slug}/{z}/{x}/{y}")
24+
async def rgeom_slug(
25+
request: Request,
26+
slug: str,
27+
z: int,
28+
x: int,
29+
y: int,
30+
):
31+
"""Get a tile from the tileserver."""
32+
return await get_rgeom(
33+
request.app.state.pool, where="slug = :slug", z=z, x=x, y=y, slug=slug
34+
)
35+
36+
37+
async def get_rgeom(pool, *, where="is_finalized = true", **params):
2238
async with pool.acquire() as con:
23-
data = await run_layer_query(
24-
con,
25-
"bounds",
26-
z=z,
27-
x=x,
28-
y=y,
29-
)
39+
data = await run_layer_query(con, "bounds", where=where, **params)
3040
kwargs = {}
3141
kwargs.setdefault("media_type", MimeTypes.pbf.value)
3242
return Response(data, **kwargs)
3343

3444

35-
async def run_layer_query(con, layer_name, **params):
36-
query = get_layer_sql(layer_name)
45+
async def run_layer_query(con, layer_name, *, where="true", **params):
46+
query = get_layer_sql(layer_name, where=where)
3747
q, p = render(query, layer_name=layer_name, **params)
3848

3949
# Overcomes a shortcoming in buildpg that deems casting to an array as unsafe
@@ -43,7 +53,7 @@ async def run_layer_query(con, layer_name, **params):
4353
return await con.fetchval(q, *p)
4454

4555

46-
def get_layer_sql(layer: str):
56+
def get_layer_sql(layer: str, *, where="true"):
4757
query = __here__ / "queries" / (layer + ".sql")
4858
q = query.read_text()
4959
q = q.strip()
@@ -52,6 +62,7 @@ def get_layer_sql(layer: str):
5262

5363
# Replace the envelope with the function call. Kind of awkward.
5464
q = q.replace(":envelope", "tile_utils.envelope(:x, :y, :z)")
65+
q = q.replace(":where", where)
5566

5667
# Wrap with MVT creation
5768
return f"WITH feature_query AS ({q}) SELECT ST_AsMVT(feature_query, :layer_name) FROM feature_query"

macrostrat_tileserver/map_bounds/queries/bounds.sql

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ WITH tile AS (
55
), sources AS (
66
SELECT
77
source_id,
8+
is_finalized,
89
name,
910
slug,
1011
scale,
@@ -15,7 +16,7 @@ WITH tile AS (
1516
FROM maps.sources, tile
1617
WHERE
1718
rgeom is NOT NULL
18-
AND status_code = 'active'
19+
AND :where
1920
AND ST_Intersects(rgeom, envelope_4326)
2021
)
2122
SELECT * FROM sources z
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
from pathlib import Path
2+
3+
from buildpg import render, Renderer
4+
from fastapi import APIRouter, Request, Response
5+
from timvt.resources.enums import MimeTypes
6+
from titiler.core.models.mapbox import TileJSON
7+
from asyncpg import UndefinedTableError
8+
from enum import Enum
9+
from macrostrat.utils import get_logger
10+
from macrostrat.database.utils import format as format_sql
11+
12+
print_sql_statements = False
13+
14+
router = APIRouter()
15+
log = get_logger("uvicorn.error")
16+
17+
__here__ = Path(__file__).parent
18+
19+
20+
class FeatureType(str, Enum):
21+
"""Feature types."""
22+
23+
polygons = "polygons"
24+
lines = "lines"
25+
points = "points"
26+
27+
28+
@router.get(
29+
"/{slug}/tilejson.json",
30+
response_model=TileJSON,
31+
responses={200: {"description": "Return a tilejson"}},
32+
response_model_exclude_none=True,
33+
)
34+
async def tilejson(
35+
request: Request,
36+
slug: str,
37+
):
38+
"""Return TileJSON document."""
39+
url_path = request.url_for(
40+
"tile", **{"slug": slug, "z": "{z}", "x": "{x}", "y": "{y}"}
41+
)
42+
43+
tile_endpoint = str(url_path)
44+
45+
bounds_query = f"""
46+
SELECT geom FROM sources.{slug}_polygons
47+
UNION
48+
SELECT geom FROM sources.{slug}_lines
49+
UNION
50+
SELECT geom FROM sources.{slug}_points
51+
"""
52+
53+
sql = get_bounds(bounds_query, geometry_column="geom")
54+
pool = request.app.state.pool
55+
async with pool.acquire() as con:
56+
bounds = await con.fetchval(sql)
57+
58+
return {
59+
"minzoom": 0,
60+
"maxzoom": 18,
61+
"name": slug,
62+
"bounds": bounds,
63+
"tiles": [tile_endpoint],
64+
}
65+
66+
67+
@router.get("/{slug}/{z}/{x}/{y}")
68+
async def tile(
69+
request: Request,
70+
slug: str,
71+
z: int,
72+
x: int,
73+
y: int,
74+
):
75+
76+
# if feature_type != FeatureType.polygons:
77+
# return Response(status_code=404, content="Only polygons are supported for now")
78+
79+
"""Get a tile from the tileserver."""
80+
pool = request.app.state.pool
81+
82+
data = b""
83+
success = False
84+
for layer in FeatureType:
85+
try:
86+
data += await get_layer(pool, slug, layer, z=z, x=x, y=y)
87+
success = True
88+
except UndefinedTableError:
89+
pass
90+
if not success:
91+
return Response(status_code=404, content=f"No tables found for {slug}")
92+
93+
kwargs = {}
94+
kwargs.setdefault("media_type", MimeTypes.pbf.value)
95+
return Response(data, **kwargs)
96+
97+
98+
async def get_layer(pool, slug, layer: FeatureType, **params):
99+
async with pool.acquire() as con:
100+
table_name = f"{slug}_{layer}"
101+
alias = "s"
102+
column_dict = await get_table_columns(con, table_name, schema="sources")
103+
log.debug("Columns: %s", column_dict)
104+
columns = [
105+
format_column(k, v, cast_empty_strings=True, table_alias=alias)
106+
for k, v in column_dict.items()
107+
if k != "geom"
108+
]
109+
columns.append("tile_layers.tile_geom(s.geom, :envelope) AS geometry")
110+
111+
joins = None
112+
if layer == FeatureType.polygons:
113+
joins = [
114+
"LEFT JOIN macrostrat.intervals i0 ON s.b_interval = i0.id",
115+
"LEFT JOIN macrostrat.intervals i1 ON s.t_interval = i1.id",
116+
]
117+
118+
b_age = "i0.age_bottom"
119+
t_age = "i1.age_top"
120+
# Eventually we will allow b_age and t_age to be set directly
121+
# b_age = "coalesce(s.b_age, i0.age_bottom)"
122+
# t_age = "coalesce(s.t_age, i1.age_top)"
123+
columns += [
124+
b_age + "::float AS b_age",
125+
t_age + "::float AS t_age",
126+
_color_subquery(b_age, t_age, "color"),
127+
]
128+
129+
return await run_layer_query(
130+
con,
131+
f"sources.{table_name}",
132+
columns,
133+
joins=joins,
134+
table_alias=alias,
135+
layer_name=f"{layer}",
136+
**params,
137+
)
138+
139+
140+
string_data_types = [
141+
"character varying",
142+
"text",
143+
]
144+
145+
146+
def format_column(
147+
col, data_type, table_alias=None, cast_empty_strings=False, name=None
148+
):
149+
val = _wrap_with_quotes(col)
150+
if name is None:
151+
name = val
152+
if table_alias is not None:
153+
val = f"{table_alias}.{val}"
154+
if cast_empty_strings and data_type in string_data_types:
155+
val = f"NULLIF({val}, '')::text"
156+
return f"{val} AS {name}"
157+
158+
159+
def _color_subquery(b_age, t_age, alias):
160+
return f"""(
161+
SELECT interval_color
162+
FROM macrostrat.intervals
163+
WHERE age_top <= {t_age} AND age_bottom >= {b_age}
164+
ORDER BY age_bottom - age_top
165+
LIMIT 1
166+
) AS {alias}"""
167+
168+
169+
async def run_layer_query(
170+
con,
171+
table_name,
172+
columns,
173+
*,
174+
joins=None,
175+
layer_name="default",
176+
table_alias=None,
177+
**params,
178+
):
179+
_cols = ", ".join(columns)
180+
query = f"SELECT {_cols} FROM {table_name}"
181+
if table_alias:
182+
query += f" AS {table_alias}"
183+
184+
if joins:
185+
query += "\n" + "\n".join(joins)
186+
187+
query = extend_sql(query)
188+
params = dict(layer_name=layer_name, **params)
189+
190+
if print_sql_statements:
191+
log.debug(
192+
"Running query:\n%s\nParameters: %s",
193+
format_sql(query, reindent=True),
194+
params,
195+
)
196+
197+
q, p = render(query, **params)
198+
199+
return await con.fetchval(q, *p)
200+
201+
202+
def _wrap_with_quotes(col):
203+
if col[0] == '"' and col[-1] == '"':
204+
col = col[1:-1]
205+
if '"' in col:
206+
col = col.replace('"', '""')
207+
return '"' + col + '"'
208+
209+
210+
def extend_sql(sql):
211+
q = sql.strip()
212+
if q.endswith(";"):
213+
q = q[:-1]
214+
215+
# Replace the envelope with the function call. Kind of awkward.
216+
q = q.replace(":envelope", "tile_utils.envelope(:x, :y, :z)")
217+
218+
# Wrap with MVT creation
219+
return f"WITH feature_query AS ({q}) SELECT ST_AsMVT(feature_query, :layer_name, 4096, 'geometry') FROM feature_query"
220+
221+
222+
def get_bounds(base_query, geometry_column="geometry"):
223+
return f"""WITH b AS (
224+
SELECT ST_Union(a.{geometry_column}::box2d)::box2d env
225+
FROM ({base_query}) a
226+
)
227+
SELECT ARRAY[ST_XMin(env), ST_YMin(env), ST_XMax(env), ST_YMax(env)]
228+
FROM b;
229+
"""
230+
231+
232+
async def get_table_columns(con, table, schema="sources"):
233+
base_sql = f"""
234+
SELECT column_name, data_type
235+
FROM information_schema.columns
236+
WHERE table_name = :table
237+
AND table_schema = :schema;
238+
"""
239+
240+
q, p = render(base_sql, table=table, schema=schema)
241+
res = await con.fetch(q, *p)
242+
if len(res) == 0:
243+
raise UndefinedTableError(f"Table {schema}.{table} not found")
244+
245+
return {i[0]: i[1] for i in res}
246+
247+
248+
def register_map_ingestion_routes(app):
249+
app.include_router(router, tags=["Map ingestion"], prefix="/ingestion")

0 commit comments

Comments
 (0)