Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cfb8c33
chore: add geopandas as optional [geo] extra
ghostiee-11 Jul 4, 2026
6ed4c8a
feat: add check_geopandas_available guard in util
ghostiee-11 Jul 4, 2026
effab59
test: cover check_geopandas_available when dep absent
ghostiee-11 Jul 4, 2026
56f89a8
fix: handle native GEOMETRY columns in DuckDBSource.get
ghostiee-11 Jul 4, 2026
d06ebc3
test: DuckDBSource returns GeoDataFrame for a GEOMETRY table
ghostiee-11 Jul 4, 2026
4e20372
feat: detect geometry columns in get_dataframe_schema
ghostiee-11 Jul 4, 2026
7fe50db
fix: make DuckDBSource.execute geometry-safe for schema queries
ghostiee-11 Jul 4, 2026
7ba574b
test: geometry schema detection and geo get_schema path
ghostiee-11 Jul 4, 2026
ec38092
test: assert GeoDataFrame survives the pipeline
ghostiee-11 Jul 4, 2026
3c2406e
feat: render geometry columns in hvPlotView with an auto kind
ghostiee-11 Jul 4, 2026
367015a
test: hvPlotView renders geometry with an auto-selected kind
ghostiee-11 Jul 4, 2026
eb180eb
feat: route geometry-column datasets to map-capable agents
ghostiee-11 Jul 4, 2026
1b178ad
test: map agents advertise a geometry-column condition
ghostiee-11 Jul 4, 2026
0508550
feat: render geometry columns as WKT in Table view
ghostiee-11 Jul 5, 2026
8993630
feat: emit GeoDataFrame as GeoJSON in DeckGLView layers
ghostiee-11 Jul 5, 2026
231bf6d
test: DeckGLView emits geometry as a GeoJSON FeatureCollection
ghostiee-11 Jul 5, 2026
70c0306
feat: auto-select geometry kind in hvPlotUIView
ghostiee-11 Jul 5, 2026
adff3d6
refactor: describe map-agent geometry capability neutrally
ghostiee-11 Jul 5, 2026
9e4284c
refactor: consolidate geometry detection into shared util helpers
ghostiee-11 Jul 5, 2026
36d5060
feat: report geometry CRS in schema and route maps on it
ghostiee-11 Jul 5, 2026
48da16d
feat: reapply geometry CRS in DuckDBSource after the WKB roundtrip
ghostiee-11 Jul 5, 2026
01169ec
feat: capture source CRS in read_geo_file for DuckDBSource
ghostiee-11 Jul 5, 2026
b9916e1
test: cover geometry CRS preservation and ingest capture
ghostiee-11 Jul 5, 2026
c5c9891
refactor: use try_import_geopandas for geopandas imports
ghostiee-11 Jul 7, 2026
ddf5a48
test: cover try_import_geopandas
ghostiee-11 Jul 7, 2026
f0820b3
refactor: simplify geometry view helpers per review
ghostiee-11 Jul 7, 2026
8626e60
fix: avoid speculatively importing geopandas
ghostiee-11 Jul 8, 2026
5fcf0c3
test: assert get_dataframe_schema does not import geopandas
ghostiee-11 Jul 8, 2026
07fa360
refactor: remove unused check_geopandas_available helper
ghostiee-11 Jul 8, 2026
333a366
refactor: let the hvPlotAgent LLM pick the geometry kind
ghostiee-11 Jul 8, 2026
11728fd
Merge remote-tracking branch 'origin/main' into feat/geopandas-schema…
ghostiee-11 Jul 8, 2026
edbb7d5
refactor: inline redundant get_data() in hvPlotUIView._get_args
ghostiee-11 Jul 8, 2026
24e31c1
fix: drop check_xarray_available reintroduced during the merge
ghostiee-11 Jul 8, 2026
0cf8f8d
test: hoist geometry-test imports to module top
ghostiee-11 Jul 9, 2026
0aa8b9a
fix: CI
ghostiee-11 Jul 9, 2026
abf9c34
fix: CI
ghostiee-11 Jul 9, 2026
5aa4d33
test: move remaining geometry-test imports to module top
ghostiee-11 Jul 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lumen/ai/agents/deck_gl.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class DeckGLAgent(BaseCodeAgent):
"Use for 3D geographic visualizations, map-based data, or when user requests DeckGL/deck.gl",
"Use for large-scale geospatial data with latitude/longitude coordinates",
"Use for hexbin aggregations, heatmaps, or 3D extruded visualizations on maps",
"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",
]
)

Expand Down
4 changes: 4 additions & 0 deletions lumen/ai/agents/hvplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class hvPlotAgent(BaseViewAgent):
default=[
"Use for exploratory data analysis, interactive plots, and dynamic filtering",
"Use for quick, iterative data visualization during analysis",
"Can render a geometry column (GeoDataFrame polygons/lines/points) as a "
"2D choropleth, shading shapes by a value column without a basemap; fits "
"geometry whose schema is not 'geographic' (a projected or absent CRS) or "
"when a basemap adds no context",
]
)

Expand Down
7 changes: 6 additions & 1 deletion lumen/ai/controls/ingest/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,12 @@ def read_geo_file(

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

Expand Down
3 changes: 2 additions & 1 deletion lumen/ai/prompts/hvPlotAgent/main.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
Generate the plot the user requested. Note that `x`, `y`, `by` and `groupby` fields MUST ALL be unique columns;
no repeated columns are allowed. Do not arbitrarily set `groupby` and `by` fields unless explicitly requested. If a
histogram is requested, use `y` instead of `x`. If x is categorical or strings, prefer barh over bar, and use `y` for
the values.
the values. If a column has `format: geometry`, set `kind` from its `geometry_type`: `polygons` for Polygon or
MultiPolygon, `paths` for LineString or MultiLineString, otherwise `points`.
{% endblock %}
61 changes: 43 additions & 18 deletions lumen/sources/duckdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
from ..transforms.sql import (
SQLCount, SQLFilter, SQLLimit, SQLSelectFrom,
)
from ..util import detect_file_encoding, normalize_table_name
from ..util import (
detect_file_encoding, normalize_table_name, try_import_geopandas,
)
from .base import BaseSQLSource, Source, cached

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

geometry_crs = param.String(default=None, allow_None=True, doc="""
CRS to reapply to geometry columns after the WKB roundtrip through
DuckDB, which stores geometry without a CRS. Populated from the source
data at ingest; may also be set explicitly for a known dataset.""")

read_only = param.Boolean(default=None, doc="""
Whether to open the DuckDB database in read-only mode.""")

Expand Down Expand Up @@ -500,11 +507,40 @@ def create_sql_expr_source(
source._file_based_tables = {**self._file_based_tables, **source._file_based_tables}
return source

def _fetch_df(
self, cursor, sql_expr: str, params: list | dict | None = None,
date_as_object: bool = False
):
"""Fetch a query result, safely handling native GEOMETRY columns.

DuckDB cannot convert a native GEOMETRY column to NumPy, so re-select
any geometry columns as WKB via ST_AsWKB before fetching, then rebuild
a GeoDataFrame when geopandas is available (WKB bytes otherwise).
"""
rel = cursor.execute(sql_expr, params) if params else cursor.execute(sql_expr)
geom_cols = [d[0] for d in rel.description if str(d[1]) == 'GEOMETRY']
if not geom_cols:
return rel.fetch_df(date_as_object=date_as_object)

selected = ', '.join(
Comment thread
ahuang11 marked this conversation as resolved.
f'ST_AsWKB("{d[0]}"::GEOMETRY) AS "{d[0]}"'
if d[0] in geom_cols else f'"{d[0]}"'
for d in rel.description
)
wrapped = f'SELECT {selected} FROM ({sql_expr})'
rel = cursor.execute(wrapped, params) if params else cursor.execute(wrapped)
df = rel.fetch_df(date_as_object=date_as_object)
if gpd := try_import_geopandas():
for col in geom_cols:
df[col] = gpd.GeoSeries.from_wkb(
df[col].apply(bytes), crs=self.geometry_crs
)
df = gpd.GeoDataFrame(df, geometry=geom_cols[0])
return df

def execute(self, sql_query: str, params: list | dict | None = None, *args, **kwargs):
with self._connection.cursor() as cursor:
if params:
return cursor.execute(sql_query, params, *args, **kwargs).fetch_df()
return cursor.execute(sql_query, *args, **kwargs).fetch_df()
return self._fetch_df(cursor, sql_query, params)

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

# Apply stored SQL parameters if available for this table
with self._connection.cursor() as cursor:
if table in self.table_params:
rel = cursor.execute(sql_expr, self.table_params[table])
else:
rel = cursor.execute(sql_expr)
has_geom = any(d[0] == 'geometry' and d[1] == 'BINARY' for d in rel.description)
df = rel.fetch_df(date_as_object=True)
if has_geom:
import geopandas as gpd
geom_rel = cursor.execute(
f'SELECT ST_AsWKB(geometry::GEOMETRY) as geometry FROM ({sql_expr})'
)
geom_df = geom_rel.fetch_df()
df['geometry'] = gpd.GeoSeries.from_wkb(geom_df.geometry.apply(bytes))
df = gpd.GeoDataFrame(df)
df = self._fetch_df(
cursor, sql_expr, self.table_params.get(table), date_as_object=True
)
if not self.filter_in_sql:
df = Filter.apply_to(df, conditions=conditions)
return df
Expand Down
9 changes: 9 additions & 0 deletions lumen/tests/ai/test_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
AnalysisAgent, ChatAgent, SQLAgent, VegaLiteAgent,
)
from lumen.ai.agents.analysis import make_analysis_model
from lumen.ai.agents.deck_gl import DeckGLAgent
from lumen.ai.agents.hvplot import hvPlotAgent
from lumen.ai.agents.sql import make_sql_model
from lumen.ai.agents.vega_lite import VegaLiteSpec, VegaLiteSpecUpdate
from lumen.ai.analysis import Analysis
Expand Down Expand Up @@ -277,6 +279,13 @@ class ExtendedChatAgent(ChatAgent):
assert "Footer appended." in prompt


def test_map_agents_route_geometry_columns():
"""hvPlot and DeckGL agents advertise a geometry-column condition so the
coordinator routes GeoDataFrame data to a map-capable view."""
assert any("geometry" in c.lower() for c in hvPlotAgent.conditions)
assert any("geometry" in c.lower() for c in DeckGLAgent.conditions)


def test_sqlagent_active_filters_describes_conditions():
"""SQLAgent._active_filters turns the interactive slider filters into
WHERE-style conditions (skipping inactive/full-range ones) so a follow-up
Expand Down
21 changes: 21 additions & 0 deletions lumen/tests/ai/test_controls/test_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@
import pytest

from lumen.ai.controls import SourceResult, UploadedFileRow
from lumen.ai.controls.ingest.utils import read_geo_file
from lumen.sources.duckdb import DuckDBSource

try:
import geopandas as gpd

from shapely.geometry import Polygon
except ImportError:
gpd = None


@pytest.mark.asyncio
class TestDocumentVectorStoreIntegration:
Expand Down Expand Up @@ -363,3 +371,16 @@ class TestUploadControlsUX:
def test_upload_button_label_is_explicit(self, upload_controls):
"""Upload controls should use explicit upload action text."""
assert upload_controls._add_button.name == "Upload file(s)"


def test_read_geo_file_captures_crs():
"""read_geo_file surfaces the source CRS in source_params so DuckDBSource
can reapply it after the WKB roundtrip (gh-1904)."""
if gpd is None:
pytest.skip("geopandas is not installed")
gdf = gpd.GeoDataFrame(
{"name": ["a"]}, geometry=[Polygon([(0, 0), (1, 0), (1, 1)])], crs="EPSG:4326"
)
buf = io.BytesIO(gdf.to_json().encode())
result = read_geo_file(buf, "geojson", "geo")
assert result.source_params["geometry_crs"] == "EPSG:4326"
101 changes: 100 additions & 1 deletion lumen/tests/sources/test_duckdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
except ImportError:
pytestmark = pytest.mark.skip(reason="Duckdb is not installed")

try:
import geopandas as gpd

from shapely.geometry import Polygon
except ImportError:
gpd = None


@pytest.fixture
def duckdb_file_source():
Expand Down Expand Up @@ -239,7 +246,10 @@ def test_duckdb_transforms_cache(duckdb_source, source_tables):
assert cache_key in duckdb_source._cache

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

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

# Ensure new tables did NOT modify the original source
assert source.execute("SHOW TABLES").shape[0] == 1 # Only 'test' table in original source


def _spatial_source():
"""Build an in-memory DuckDBSource holding a native GEOMETRY table.

Mirrors the ingest flow: WKB bytes -> ST_GeomFromWKB -> GEOMETRY column.
Skips if the duckdb spatial extension cannot be loaded (needs network on
first install).
"""
if gpd is None:
pytest.skip("geopandas is not installed")
try:
source = DuckDBSource(
uri=':memory:',
initializers=["INSTALL spatial;", "LOAD spatial;"],
tables={'geo': 'SELECT * FROM geo_tbl'},
)
except Exception as e: # pragma: no cover - environment dependent
pytest.skip(f"duckdb spatial extension unavailable: {e}")
gdf = gpd.GeoDataFrame(
{
'name': ['a', 'b'],
'pop': [1, 2],
'geometry': [
Polygon([(0, 0), (1, 0), (1, 1)]),
Polygon([(2, 0), (3, 0), (3, 1)]),
],
},
crs='EPSG:4326',
)
tmp = pd.DataFrame(
{'name': gdf['name'], 'pop': gdf['pop'], 'geometry': gdf['geometry'].to_wkb()}
)
source._connection.register('geo_temp', tmp)
source._connection.execute(
'CREATE TABLE geo_tbl AS '
'SELECT name, pop, ST_GeomFromWKB(geometry) AS geometry FROM geo_temp'
)
return source, gpd


def test_duckdb_geometry_returns_geodataframe():
"""A native GEOMETRY column round-trips to a GeoDataFrame without crashing."""
source, gpd = _spatial_source()
result = source.get('geo')
assert isinstance(result, gpd.GeoDataFrame)
assert 'geometry' in result.columns
assert len(result) == 2
assert result.geometry.notna().all()
assert str(result.geometry.dtype) == 'geometry'


def test_duckdb_geometry_crs_none_by_default():
"""Without geometry_crs set the CRS stays None (WKB carries none); no regression."""
source, gpd = _spatial_source()
assert source.get('geo').crs is None


def test_duckdb_geometry_crs_preserved():
"""geometry_crs is reapplied when rebuilding the GeoDataFrame (gh-1904)."""
source, gpd = _spatial_source()
source.geometry_crs = 'EPSG:4326'
result = source.get('geo')
assert result.crs is not None
assert result.crs.to_epsg() == 4326


def test_duckdb_geometry_crs_propagates_to_derived_source():
"""A source created via create_sql_expr_source keeps geometry_crs (gh-1904)."""
source, gpd = _spatial_source()
source.geometry_crs = 'EPSG:4326'
derived = source.create_sql_expr_source(
{'geo2': 'SELECT * FROM geo'}, materialize=False
)
assert derived.geometry_crs == 'EPSG:4326'
assert derived.get('geo').crs.to_epsg() == 4326


def test_duckdb_get_schema_geometry_no_distinct():
"""get_schema on a geometry table returns a geospatial marker and does not
run DISTINCT/min-max on the geometry column."""
source, gpd = _spatial_source()
schema = source.get_schema('geo')
assert schema['geometry']['format'] == 'geometry'
assert 'enum' not in schema['geometry']
assert 'inclusiveMinimum' not in schema['geometry']
# non-geometry columns still summarised as usual
assert schema['pop']['inclusiveMinimum'] == 1
assert schema['pop']['inclusiveMaximum'] == 2
28 changes: 27 additions & 1 deletion lumen/tests/sources/test_optional_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import pytest

from lumen.util import try_import_xarray
from lumen.util import try_import_geopandas, try_import_xarray

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


def test_try_import_geopandas_returns_module_when_installed():
"""try_import_geopandas returns the geopandas module when importable."""
gpd = pytest.importorskip("geopandas")
assert try_import_geopandas() is gpd


def test_try_import_geopandas_none_when_missing():
"""try_import_geopandas returns None (not raises) when geopandas is absent."""
real_import = builtins.__import__

def mock_import(name, *args, **kwargs):
if name == "geopandas" or name.startswith("geopandas."):
raise ImportError("No module named 'geopandas'")
return real_import(name, *args, **kwargs)

saved = {
key: sys.modules.pop(key)
for key in [k for k in sys.modules if k == "geopandas" or k.startswith("geopandas.")]
}
try:
with patch("builtins.__import__", side_effect=mock_import):
assert try_import_geopandas() is None
finally:
sys.modules.update(saved)


def test_try_import_xarray_returns_module_when_installed():
"""try_import_xarray returns the xarray module when xarray and xarray-sql are installed."""
xr = pytest.importorskip("xarray")
Expand Down
Loading