Skip to content

Commit 59695a6

Browse files
authored
Support geometry columns end-to-end so GeoDataFrames render as maps (#1903)
1 parent 77536c1 commit 59695a6

15 files changed

Lines changed: 555 additions & 29 deletions

File tree

lumen/ai/agents/deck_gl.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ class DeckGLAgent(BaseCodeAgent):
9696
"Use for 3D geographic visualizations, map-based data, or when user requests DeckGL/deck.gl",
9797
"Use for large-scale geospatial data with latitude/longitude coordinates",
9898
"Use for hexbin aggregations, heatmaps, or 3D extruded visualizations on maps",
99+
"Can render a geometry column (GeoDataFrame polygons/lines/points) on a basemap, suited to 3D/extruded maps or geometry whose schema is 'geographic' (a lat/lon CRS) so the basemap gives real-world context",
99100
]
100101
)
101102

lumen/ai/agents/hvplot.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class hvPlotAgent(BaseViewAgent):
1919
default=[
2020
"Use for exploratory data analysis, interactive plots, and dynamic filtering",
2121
"Use for quick, iterative data visualization during analysis",
22+
"Can render a geometry column (GeoDataFrame polygons/lines/points) as a "
23+
"2D choropleth, shading shapes by a value column without a basemap; fits "
24+
"geometry whose schema is not 'geographic' (a projected or absent CRS) or "
25+
"when a basemap adds no context",
2226
]
2327
)
2428

lumen/ai/controls/ingest/utils.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,12 @@ def read_geo_file(
326326

327327
return FileReadResult(
328328
tables={alias: df},
329-
source_params={"initializers": ["INSTALL spatial;\nLOAD spatial;"]},
329+
source_params={
330+
"initializers": ["INSTALL spatial;\nLOAD spatial;"],
331+
# DuckDB stores geometry without a CRS, so carry the source CRS
332+
# through for DuckDBSource to reapply after the WKB roundtrip.
333+
"geometry_crs": str(geo_df.crs) if geo_df.crs is not None else None,
334+
},
330335
conversions={alias: conversion},
331336
)
332337

lumen/ai/prompts/hvPlotAgent/main.jinja2

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@
44
Generate the plot the user requested. Note that `x`, `y`, `by` and `groupby` fields MUST ALL be unique columns;
55
no repeated columns are allowed. Do not arbitrarily set `groupby` and `by` fields unless explicitly requested. If a
66
histogram is requested, use `y` instead of `x`. If x is categorical or strings, prefer barh over bar, and use `y` for
7-
the values.
7+
the values. If a column has `format: geometry`, set `kind` from its `geometry_type`: `polygons` for Polygon or
8+
MultiPolygon, `paths` for LineString or MultiLineString, otherwise `points`.
89
{% endblock %}

lumen/sources/duckdb.py

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
from ..transforms.sql import (
2020
SQLCount, SQLFilter, SQLLimit, SQLSelectFrom,
2121
)
22-
from ..util import detect_file_encoding, normalize_table_name
22+
from ..util import (
23+
detect_file_encoding, normalize_table_name, try_import_geopandas,
24+
)
2325
from .base import BaseSQLSource, Source, cached
2426

2527
if TYPE_CHECKING:
@@ -66,6 +68,11 @@ class DuckDBSource(BaseSQLSource):
6668
Whether the data is ephemeral, i.e. manually inserted into the
6769
DuckDB table or derived from real data.""")
6870

71+
geometry_crs = param.String(default=None, allow_None=True, doc="""
72+
CRS to reapply to geometry columns after the WKB roundtrip through
73+
DuckDB, which stores geometry without a CRS. Populated from the source
74+
data at ingest; may also be set explicitly for a known dataset.""")
75+
6976
read_only = param.Boolean(default=None, doc="""
7077
Whether to open the DuckDB database in read-only mode.""")
7178

@@ -500,11 +507,40 @@ def create_sql_expr_source(
500507
source._file_based_tables = {**self._file_based_tables, **source._file_based_tables}
501508
return source
502509

510+
def _fetch_df(
511+
self, cursor, sql_expr: str, params: list | dict | None = None,
512+
date_as_object: bool = False
513+
):
514+
"""Fetch a query result, safely handling native GEOMETRY columns.
515+
516+
DuckDB cannot convert a native GEOMETRY column to NumPy, so re-select
517+
any geometry columns as WKB via ST_AsWKB before fetching, then rebuild
518+
a GeoDataFrame when geopandas is available (WKB bytes otherwise).
519+
"""
520+
rel = cursor.execute(sql_expr, params) if params else cursor.execute(sql_expr)
521+
geom_cols = [d[0] for d in rel.description if str(d[1]) == 'GEOMETRY']
522+
if not geom_cols:
523+
return rel.fetch_df(date_as_object=date_as_object)
524+
525+
selected = ', '.join(
526+
f'ST_AsWKB("{d[0]}"::GEOMETRY) AS "{d[0]}"'
527+
if d[0] in geom_cols else f'"{d[0]}"'
528+
for d in rel.description
529+
)
530+
wrapped = f'SELECT {selected} FROM ({sql_expr})'
531+
rel = cursor.execute(wrapped, params) if params else cursor.execute(wrapped)
532+
df = rel.fetch_df(date_as_object=date_as_object)
533+
if gpd := try_import_geopandas():
534+
for col in geom_cols:
535+
df[col] = gpd.GeoSeries.from_wkb(
536+
df[col].apply(bytes), crs=self.geometry_crs
537+
)
538+
df = gpd.GeoDataFrame(df, geometry=geom_cols[0])
539+
return df
540+
503541
def execute(self, sql_query: str, params: list | dict | None = None, *args, **kwargs):
504542
with self._connection.cursor() as cursor:
505-
if params:
506-
return cursor.execute(sql_query, params, *args, **kwargs).fetch_df()
507-
return cursor.execute(sql_query, *args, **kwargs).fetch_df()
543+
return self._fetch_df(cursor, sql_query, params)
508544

509545
def get_tables(self):
510546
if isinstance(self.tables, dict | list):
@@ -538,20 +574,9 @@ def get(self, table, **query):
538574

539575
# Apply stored SQL parameters if available for this table
540576
with self._connection.cursor() as cursor:
541-
if table in self.table_params:
542-
rel = cursor.execute(sql_expr, self.table_params[table])
543-
else:
544-
rel = cursor.execute(sql_expr)
545-
has_geom = any(d[0] == 'geometry' and d[1] == 'BINARY' for d in rel.description)
546-
df = rel.fetch_df(date_as_object=True)
547-
if has_geom:
548-
import geopandas as gpd
549-
geom_rel = cursor.execute(
550-
f'SELECT ST_AsWKB(geometry::GEOMETRY) as geometry FROM ({sql_expr})'
551-
)
552-
geom_df = geom_rel.fetch_df()
553-
df['geometry'] = gpd.GeoSeries.from_wkb(geom_df.geometry.apply(bytes))
554-
df = gpd.GeoDataFrame(df)
577+
df = self._fetch_df(
578+
cursor, sql_expr, self.table_params.get(table), date_as_object=True
579+
)
555580
if not self.filter_in_sql:
556581
df = Filter.apply_to(df, conditions=conditions)
557582
return df

lumen/tests/ai/test_agents.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
AnalysisAgent, ChatAgent, SQLAgent, VegaLiteAgent,
2121
)
2222
from lumen.ai.agents.analysis import make_analysis_model
23+
from lumen.ai.agents.deck_gl import DeckGLAgent
24+
from lumen.ai.agents.hvplot import hvPlotAgent
2325
from lumen.ai.agents.sql import make_sql_model
2426
from lumen.ai.agents.vega_lite import VegaLiteSpec, VegaLiteSpecUpdate
2527
from lumen.ai.analysis import Analysis
@@ -277,6 +279,13 @@ class ExtendedChatAgent(ChatAgent):
277279
assert "Footer appended." in prompt
278280

279281

282+
def test_map_agents_route_geometry_columns():
283+
"""hvPlot and DeckGL agents advertise a geometry-column condition so the
284+
coordinator routes GeoDataFrame data to a map-capable view."""
285+
assert any("geometry" in c.lower() for c in hvPlotAgent.conditions)
286+
assert any("geometry" in c.lower() for c in DeckGLAgent.conditions)
287+
288+
280289
def test_sqlagent_active_filters_describes_conditions():
281290
"""SQLAgent._active_filters turns the interactive slider filters into
282291
WHERE-style conditions (skipping inactive/full-range ones) so a follow-up

lumen/tests/ai/test_controls/test_upload.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@
77
import pytest
88

99
from lumen.ai.controls import SourceResult, UploadedFileRow
10+
from lumen.ai.controls.ingest.utils import read_geo_file
1011
from lumen.sources.duckdb import DuckDBSource
1112

13+
try:
14+
import geopandas as gpd
15+
16+
from shapely.geometry import Polygon
17+
except ImportError:
18+
gpd = None
19+
1220

1321
@pytest.mark.asyncio
1422
class TestDocumentVectorStoreIntegration:
@@ -363,3 +371,16 @@ class TestUploadControlsUX:
363371
def test_upload_button_label_is_explicit(self, upload_controls):
364372
"""Upload controls should use explicit upload action text."""
365373
assert upload_controls._add_button.name == "Upload file(s)"
374+
375+
376+
def test_read_geo_file_captures_crs():
377+
"""read_geo_file surfaces the source CRS in source_params so DuckDBSource
378+
can reapply it after the WKB roundtrip (gh-1904)."""
379+
if gpd is None:
380+
pytest.skip("geopandas is not installed")
381+
gdf = gpd.GeoDataFrame(
382+
{"name": ["a"]}, geometry=[Polygon([(0, 0), (1, 0), (1, 1)])], crs="EPSG:4326"
383+
)
384+
buf = io.BytesIO(gdf.to_json().encode())
385+
result = read_geo_file(buf, "geojson", "geo")
386+
assert result.source_params["geometry_crs"] == "EPSG:4326"

lumen/tests/sources/test_duckdb.py

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@
1818
except ImportError:
1919
pytestmark = pytest.mark.skip(reason="Duckdb is not installed")
2020

21+
try:
22+
import geopandas as gpd
23+
24+
from shapely.geometry import Polygon
25+
except ImportError:
26+
gpd = None
27+
2128

2229
@pytest.fixture
2330
def duckdb_file_source():
@@ -239,7 +246,10 @@ def test_duckdb_transforms_cache(duckdb_source, source_tables):
239246
assert cache_key in duckdb_source._cache
240247

241248
expected = df_test_sql.groupby('B')['A'].sum().reset_index()
242-
pd.testing.assert_frame_equal(duckdb_source._cache[cache_key], expected)
249+
# DuckDB GROUP BY returns rows in a nondeterministic order; sort by the
250+
# group key before comparing against pandas' sorted groupby output.
251+
actual = duckdb_source._cache[cache_key].sort_values('B').reset_index(drop=True)
252+
pd.testing.assert_frame_equal(actual, expected)
243253

244254
cache_key = duckdb_source._get_key('test_sql', sql_transforms=transforms)
245255
assert cache_key in duckdb_source._cache
@@ -1345,3 +1355,92 @@ def test_read_only_create_sql_expr_source_expands_tables(duckdb_file_source):
13451355

13461356
# Ensure new tables did NOT modify the original source
13471357
assert source.execute("SHOW TABLES").shape[0] == 1 # Only 'test' table in original source
1358+
1359+
1360+
def _spatial_source():
1361+
"""Build an in-memory DuckDBSource holding a native GEOMETRY table.
1362+
1363+
Mirrors the ingest flow: WKB bytes -> ST_GeomFromWKB -> GEOMETRY column.
1364+
Skips if the duckdb spatial extension cannot be loaded (needs network on
1365+
first install).
1366+
"""
1367+
if gpd is None:
1368+
pytest.skip("geopandas is not installed")
1369+
try:
1370+
source = DuckDBSource(
1371+
uri=':memory:',
1372+
initializers=["INSTALL spatial;", "LOAD spatial;"],
1373+
tables={'geo': 'SELECT * FROM geo_tbl'},
1374+
)
1375+
except Exception as e: # pragma: no cover - environment dependent
1376+
pytest.skip(f"duckdb spatial extension unavailable: {e}")
1377+
gdf = gpd.GeoDataFrame(
1378+
{
1379+
'name': ['a', 'b'],
1380+
'pop': [1, 2],
1381+
'geometry': [
1382+
Polygon([(0, 0), (1, 0), (1, 1)]),
1383+
Polygon([(2, 0), (3, 0), (3, 1)]),
1384+
],
1385+
},
1386+
crs='EPSG:4326',
1387+
)
1388+
tmp = pd.DataFrame(
1389+
{'name': gdf['name'], 'pop': gdf['pop'], 'geometry': gdf['geometry'].to_wkb()}
1390+
)
1391+
source._connection.register('geo_temp', tmp)
1392+
source._connection.execute(
1393+
'CREATE TABLE geo_tbl AS '
1394+
'SELECT name, pop, ST_GeomFromWKB(geometry) AS geometry FROM geo_temp'
1395+
)
1396+
return source, gpd
1397+
1398+
1399+
def test_duckdb_geometry_returns_geodataframe():
1400+
"""A native GEOMETRY column round-trips to a GeoDataFrame without crashing."""
1401+
source, gpd = _spatial_source()
1402+
result = source.get('geo')
1403+
assert isinstance(result, gpd.GeoDataFrame)
1404+
assert 'geometry' in result.columns
1405+
assert len(result) == 2
1406+
assert result.geometry.notna().all()
1407+
assert str(result.geometry.dtype) == 'geometry'
1408+
1409+
1410+
def test_duckdb_geometry_crs_none_by_default():
1411+
"""Without geometry_crs set the CRS stays None (WKB carries none); no regression."""
1412+
source, gpd = _spatial_source()
1413+
assert source.get('geo').crs is None
1414+
1415+
1416+
def test_duckdb_geometry_crs_preserved():
1417+
"""geometry_crs is reapplied when rebuilding the GeoDataFrame (gh-1904)."""
1418+
source, gpd = _spatial_source()
1419+
source.geometry_crs = 'EPSG:4326'
1420+
result = source.get('geo')
1421+
assert result.crs is not None
1422+
assert result.crs.to_epsg() == 4326
1423+
1424+
1425+
def test_duckdb_geometry_crs_propagates_to_derived_source():
1426+
"""A source created via create_sql_expr_source keeps geometry_crs (gh-1904)."""
1427+
source, gpd = _spatial_source()
1428+
source.geometry_crs = 'EPSG:4326'
1429+
derived = source.create_sql_expr_source(
1430+
{'geo2': 'SELECT * FROM geo'}, materialize=False
1431+
)
1432+
assert derived.geometry_crs == 'EPSG:4326'
1433+
assert derived.get('geo').crs.to_epsg() == 4326
1434+
1435+
1436+
def test_duckdb_get_schema_geometry_no_distinct():
1437+
"""get_schema on a geometry table returns a geospatial marker and does not
1438+
run DISTINCT/min-max on the geometry column."""
1439+
source, gpd = _spatial_source()
1440+
schema = source.get_schema('geo')
1441+
assert schema['geometry']['format'] == 'geometry'
1442+
assert 'enum' not in schema['geometry']
1443+
assert 'inclusiveMinimum' not in schema['geometry']
1444+
# non-geometry columns still summarised as usual
1445+
assert schema['pop']['inclusiveMinimum'] == 1
1446+
assert schema['pop']['inclusiveMaximum'] == 2

lumen/tests/sources/test_optional_imports.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import pytest
1616

17-
from lumen.util import try_import_xarray
17+
from lumen.util import try_import_geopandas, try_import_xarray
1818

1919
# Each entry: (module_path, class_name, guard_package, pip_extra)
2020
OPTIONAL_SOURCES = [
@@ -88,6 +88,32 @@ def mock_import(name, *args, **kwargs):
8888
sys.modules.update(saved)
8989

9090

91+
def test_try_import_geopandas_returns_module_when_installed():
92+
"""try_import_geopandas returns the geopandas module when importable."""
93+
gpd = pytest.importorskip("geopandas")
94+
assert try_import_geopandas() is gpd
95+
96+
97+
def test_try_import_geopandas_none_when_missing():
98+
"""try_import_geopandas returns None (not raises) when geopandas is absent."""
99+
real_import = builtins.__import__
100+
101+
def mock_import(name, *args, **kwargs):
102+
if name == "geopandas" or name.startswith("geopandas."):
103+
raise ImportError("No module named 'geopandas'")
104+
return real_import(name, *args, **kwargs)
105+
106+
saved = {
107+
key: sys.modules.pop(key)
108+
for key in [k for k in sys.modules if k == "geopandas" or k.startswith("geopandas.")]
109+
}
110+
try:
111+
with patch("builtins.__import__", side_effect=mock_import):
112+
assert try_import_geopandas() is None
113+
finally:
114+
sys.modules.update(saved)
115+
116+
91117
def test_try_import_xarray_returns_module_when_installed():
92118
"""try_import_xarray returns the xarray module when xarray and xarray-sql are installed."""
93119
xr = pytest.importorskip("xarray")

0 commit comments

Comments
 (0)