core-lens 0.1.dev106__tar.gz → 0.1.dev109__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/PKG-INFO +4 -1
  2. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/pyproject.toml +4 -0
  3. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/_version.py +2 -2
  4. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/aoi.py +18 -30
  5. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/namespaces/plot.py +136 -42
  6. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/result.py +1 -5
  7. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/export/formats.py +14 -8
  8. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/utils/polars_utils.py +4 -10
  9. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/utils/spatial.py +65 -40
  10. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/uv.lock +6 -0
  11. core_lens-0.1.dev106/plot_wb.py +0 -29
  12. core_lens-0.1.dev106/wb_annual.txt +0 -11
  13. core_lens-0.1.dev106/wb_static.txt +0 -11
  14. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.github/pull_request_template.md +0 -0
  15. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.github/workflows/ci.yml +0 -0
  16. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.github/workflows/gh-pages.yml +0 -0
  17. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.github/workflows/pre-release.yml +0 -0
  18. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.github/workflows/release.yml +0 -0
  19. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.gitignore +0 -0
  20. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.gitmessage +0 -0
  21. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.pre-commit-config.yaml +0 -0
  22. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/.python-version +0 -0
  23. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/CONTRIBUTING.md +0 -0
  24. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/LICENSE +0 -0
  25. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/README.md +0 -0
  26. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/SKILLS.md +0 -0
  27. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/Makefile +0 -0
  28. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/make.bat +0 -0
  29. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/concepts.md +0 -0
  30. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/conf.py +0 -0
  31. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/index.rst +0 -0
  32. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/intro.md +0 -0
  33. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/plots.md +0 -0
  34. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/plugins.md +0 -0
  35. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/queries.md +0 -0
  36. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/quickstart.md +0 -0
  37. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/docs/source/stats.md +0 -0
  38. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/examples/demo_mws.py +0 -0
  39. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/examples/demo_tehsil.py +0 -0
  40. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/hooks/mypy.sh +0 -0
  41. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/hooks/no-parquet-outside-fixtures.sh +0 -0
  42. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/hooks/pytest.sh +0 -0
  43. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/__init__.py +0 -0
  44. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/__main__.py +0 -0
  45. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/__init__.py +0 -0
  46. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/entity.py +0 -0
  47. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/namespaces/__init__.py +0 -0
  48. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/namespaces/stats.py +0 -0
  49. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/base/view.py +0 -0
  50. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/entities/__init__.py +0 -0
  51. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/entities/mws.py +0 -0
  52. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/entities/tehsil.py +0 -0
  53. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/entities/waterbody.py +0 -0
  54. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/export/__init__.py +0 -0
  55. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/py.typed +0 -0
  56. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/schema/__init__.py +0 -0
  57. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/schema/detection.py +0 -0
  58. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/schema/profile.py +0 -0
  59. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/utils/__init__.py +0 -0
  60. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/src/core_lens/utils/season.py +0 -0
  61. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/fixtures/generate_fixtures.py +0 -0
  62. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/conftest.py +0 -0
  63. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_aoi.py +0 -0
  64. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_entities.py +0 -0
  65. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_entity.py +0 -0
  66. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_export.py +0 -0
  67. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_main.py +0 -0
  68. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_plot.py +0 -0
  69. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_polars_utils.py +0 -0
  70. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_profile.py +0 -0
  71. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_result.py +0 -0
  72. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_schema_detection.py +0 -0
  73. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_schema_profile.py +0 -0
  74. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_season.py +0 -0
  75. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_season_config.py +0 -0
  76. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_spatial.py +0 -0
  77. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_stats.py +0 -0
  78. {core_lens-0.1.dev106 → core_lens-0.1.dev109}/tests/unit/test_view.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-lens
3
- Version: 0.1.dev106
3
+ Version: 0.1.dev109
4
4
  Summary: Query, analyse, and visualise CoreStack's microwatershed and Earth science data through a clean, composable Python API.
5
5
  Project-URL: Homepage, https://github.com/ApoorvaKashyap/core-lens
6
6
  Project-URL: Issues, https://github.com/ApoorvaKashyap/core-lens/issues
@@ -23,6 +23,7 @@ Requires-Dist: pydantic<3,>=2.13.3; extra == 'core'
23
23
  Provides-Extra: full
24
24
  Requires-Dist: bokeh<4,>=3.6.0; extra == 'full'
25
25
  Requires-Dist: duckdb<2,>=1.5.3; extra == 'full'
26
+ Requires-Dist: geoarrow-rust-core>=0.3.0; extra == 'full'
26
27
  Requires-Dist: geopandas<2,>=1.1.3; extra == 'full'
27
28
  Requires-Dist: lonboard<1,>=0.16.0; extra == 'full'
28
29
  Requires-Dist: matplotlib<4,>=3.10.9; extra == 'full'
@@ -38,6 +39,7 @@ Requires-Dist: bokeh<4,>=3.6.0; extra == 'full-gpu'
38
39
  Requires-Dist: cudf-cu13==26.6.*; extra == 'full-gpu'
39
40
  Requires-Dist: cudf-polars-cu13==26.6.*; extra == 'full-gpu'
40
41
  Requires-Dist: duckdb<2,>=1.5.3; extra == 'full-gpu'
42
+ Requires-Dist: geoarrow-rust-core>=0.3.0; extra == 'full-gpu'
41
43
  Requires-Dist: geopandas<2,>=1.1.3; extra == 'full-gpu'
42
44
  Requires-Dist: lonboard<1,>=0.16.0; extra == 'full-gpu'
43
45
  Requires-Dist: matplotlib<4,>=3.10.9; extra == 'full-gpu'
@@ -70,6 +72,7 @@ Requires-Dist: scipy<2,>=1.17.1; extra == 'stats'
70
72
  Requires-Dist: statsmodels<1,>=0.14.6; extra == 'stats'
71
73
  Provides-Extra: viz
72
74
  Requires-Dist: bokeh<4,>=3.6.0; extra == 'viz'
75
+ Requires-Dist: geoarrow-rust-core>=0.3.0; extra == 'viz'
73
76
  Requires-Dist: lonboard<1,>=0.16.0; extra == 'viz'
74
77
  Requires-Dist: matplotlib<4,>=3.10.9; extra == 'viz'
75
78
  Requires-Dist: polars<2,>=1.39.0; extra == 'viz'
@@ -55,6 +55,7 @@ spatial = [
55
55
  viz = [
56
56
  "core-lens[core]",
57
57
  "lonboard>=0.16.0,<1",
58
+ "geoarrow-rust-core>=0.3.0",
58
59
  "matplotlib>=3.10.9,<4",
59
60
  "bokeh>=3.6.0,<4",
60
61
  ]
@@ -107,3 +108,6 @@ explicit = true
107
108
  [tool.uv.sources]
108
109
  cudf-cu13 = { index = "nvidia" }
109
110
  cudf-polars-cu13 = { index = "nvidia" }
111
+
112
+ [tool.coverage.run]
113
+ source = ["src/core_lens"]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.dev106'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev106')
21
+ __version__ = version = '0.1.dev109'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev109')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -247,6 +247,7 @@ class AoI:
247
247
 
248
248
  layers = []
249
249
 
250
+ # Base layer: single AoI boundary (one Shapely geom — GeoPandas is fine here).
250
251
  aoi_gdf = gpd.GeoDataFrame(geometry=[self.geometry], crs="EPSG:4326")
251
252
  base_layer = lonboard.PolygonLayer.from_geopandas(
252
253
  aoi_gdf,
@@ -261,33 +262,19 @@ class AoI:
261
262
  if hasattr(overlay, "has_geometry") and not overlay.has_geometry:
262
263
  overlay = overlay.with_geometry()
263
264
 
264
- if hasattr(overlay, "gdf"):
265
- gdf = overlay.gdf()
266
-
267
- # Lonboard PolygonLayer only supports Polygon and MultiPolygon.
268
- # Extract polygon parts from GeometryCollections rather than dropping them.
269
- def _extract_polygons(geom: Any) -> Any:
270
- if geom is None:
271
- return None
272
- if geom.geom_type in ("Polygon", "MultiPolygon"):
273
- return geom
274
- if geom.geom_type == "GeometryCollection":
275
- from shapely.geometry import MultiPolygon
276
-
277
- polys = [
278
- g
279
- for g in getattr(geom, "geoms", [])
280
- if g.geom_type in ("Polygon", "MultiPolygon")
281
- ]
282
- if polys:
283
- return MultiPolygon(polys)
284
- return None
285
-
286
- gdf["geometry"] = gdf.geometry.apply(_extract_polygons)
287
- gdf = gdf[gdf.geometry.notna()].copy()
288
-
289
- overlay_layer = lonboard.PolygonLayer.from_geopandas(
290
- gdf,
265
+ if hasattr(overlay, "entity") and hasattr(overlay, "data"):
266
+ # --- Direct Polars → GeoArrow → Lonboard (no Shapely, no GeoPandas) ---
267
+ from core_lens.base.namespaces.plot import _wkb_to_arrow_table
268
+
269
+ geom_col = overlay.entity.geometry_col
270
+ key_cols = (
271
+ list(overlay.key_cols) if hasattr(overlay, "key_cols") else []
272
+ )
273
+ arrow_table = _wkb_to_arrow_table(
274
+ overlay.data, geom_col, extra_cols=key_cols
275
+ )
276
+ overlay_layer = lonboard.PolygonLayer(
277
+ arrow_table,
291
278
  get_fill_color=[255, 0, 0, 100],
292
279
  get_line_color=[255, 0, 0, 200],
293
280
  line_width_min_pixels=1,
@@ -332,7 +319,6 @@ class AoI:
332
319
  ValueError: If the filters match zero rows.
333
320
  """
334
321
  import shapely.ops as sops
335
- import shapely.wkb as swkb
336
322
 
337
323
  # Find the registered entity whose key_col or known attribute column
338
324
  # matches one of the filter keys.
@@ -374,7 +360,7 @@ class AoI:
374
360
  or col in candidate.schema_profile.extra_static_cols
375
361
  ):
376
362
  if isinstance(val, list):
377
- filter_expr = filter_expr & pl.col(col).is_in(val)
363
+ lf = lf.join(pl.LazyFrame({col: val}), on=col, how="inner")
378
364
  else:
379
365
  filter_expr = filter_expr & (pl.col(col) == val)
380
366
 
@@ -386,7 +372,9 @@ class AoI:
386
372
  f"in {candidate.static_path!r}."
387
373
  )
388
374
 
389
- geoms = [swkb.loads(row) for row in df[geom_col].to_list()]
375
+ import shapely
376
+
377
+ geoms = shapely.from_wkb(df[geom_col].to_numpy())
390
378
  return sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
391
379
 
392
380
  @classmethod
@@ -45,6 +45,110 @@ _PALETTE = [
45
45
  ]
46
46
 
47
47
 
48
+ def _wkb_to_arrow_table(
49
+ df: Any,
50
+ geom_col: str,
51
+ extra_cols: list[str] | None = None,
52
+ ) -> Any:
53
+ """Convert a Polars DataFrame with a WKB geometry column to a GeoArrow PyArrow Table.
54
+
55
+ Uses ``geoarrow.rust.core.from_wkb`` — a zero-copy C extension bundled
56
+ transitively by lonboard via ``geoarrow-rust-core``. No Shapely objects
57
+ are ever instantiated for the common case (Polygon / MultiPolygon rows).
58
+
59
+ GeometryCollection rows are handled by extracting their polygon parts via
60
+ Shapely, matching the behaviour of the previous GeoPandas code path. Because
61
+ GeometryCollections are rare in practice the Shapely overhead is bounded.
62
+
63
+ WKB type byte encoding (after the 1-byte endian flag):
64
+ 1=Point, 2=LineString, 3=Polygon, 4=MultiPoint, 5=MultiLineString,
65
+ 6=MultiPolygon, 7=GeometryCollection.
66
+
67
+ Args:
68
+ df: A Polars DataFrame containing the WKB binary column.
69
+ geom_col: Name of the WKB binary column in *df*.\
70
+ extra_cols: Additional columns to include in the output table.
71
+
72
+ Returns:
73
+ An ``arro3.core.Table`` whose geometry column is GeoArrow-typed.
74
+ """
75
+ import geoarrow.rust.core as ga
76
+ import polars as pl
77
+
78
+ keep = [geom_col] + (extra_cols or [])
79
+ df = df.select([c for c in keep if c in df.columns])
80
+
81
+ def _wkb_geom_type(b: bytes) -> int:
82
+ """Return the WKB geometry type integer (1-7), or 0 on invalid input."""
83
+ if not b or len(b) < 5:
84
+ return 0
85
+ endian = b[0] # 1 = little-endian, 0 = big-endian
86
+ raw = int.from_bytes(b[1:5], "little" if endian == 1 else "big")
87
+ return raw & 0xFFFF # strip ISO Z/M/SRID flags
88
+
89
+ geom_type_series = df[geom_col].map_elements(_wkb_geom_type, return_dtype=pl.Int32)
90
+
91
+ # --- Fast path: pure Polygon / MultiPolygon rows (no Shapely) ---
92
+ poly_mask = geom_type_series.is_in([3, 6])
93
+ df_poly = df.filter(poly_mask)
94
+
95
+ # --- Slow path: GeometryCollection rows — extract polygon parts via Shapely ---
96
+ gc_mask = geom_type_series == 7
97
+ df_gc = df.filter(gc_mask)
98
+
99
+ if df_gc.height > 0:
100
+ import shapely.wkb as swkb
101
+ from shapely.geometry import MultiPolygon
102
+
103
+ repaired_rows: list[dict[str, Any]] = []
104
+ other_cols = [c for c in df_gc.columns if c != geom_col]
105
+
106
+ for row in df_gc.iter_rows(named=True):
107
+ raw_wkb = row[geom_col]
108
+ geom = swkb.loads(raw_wkb)
109
+ polys = [
110
+ g
111
+ for g in getattr(geom, "geoms", [])
112
+ if g.geom_type in ("Polygon", "MultiPolygon")
113
+ ]
114
+ if not polys:
115
+ continue
116
+ merged = MultiPolygon(polys) if len(polys) > 1 else polys[0]
117
+ new_row = {geom_col: swkb.dumps(merged)}
118
+ for col in other_cols:
119
+ new_row[col] = row[col]
120
+ repaired_rows.append(new_row)
121
+
122
+ if repaired_rows:
123
+ schema = {geom_col: pl.Binary}
124
+ for col in other_cols:
125
+ schema[col] = df_gc[col].dtype
126
+ df_gc_repaired = pl.DataFrame(repaired_rows, schema=schema)
127
+ df_poly = pl.concat([df_poly, df_gc_repaired], how="diagonal")
128
+
129
+ import arro3.core as ac
130
+
131
+ if df_poly.height == 0:
132
+ # Return an empty table with the correct schema rather than crashing.
133
+ arrow_table = df_poly.to_arrow()
134
+ return ac.Table.from_arrow(arrow_table)
135
+
136
+ arrow_table = df_poly.to_arrow()
137
+ wkb_col = arrow_table.column(geom_col).combine_chunks()
138
+ geo_col = ga.from_wkb(wkb_col)
139
+
140
+ idx = arrow_table.schema.get_field_index(geom_col)
141
+ schema = arrow_table.schema.remove(idx)
142
+
143
+ tbl_cols = [ac.ChunkedArray(arrow_table.column(c)) for c in schema.names]
144
+ tbl_cols.insert(idx, ac.ChunkedArray([ac.Array(geo_col)]))
145
+ names = list(schema.names)
146
+ names.insert(idx, geom_col)
147
+
148
+ tbl = ac.Table.from_arrays(tbl_cols, names=names)
149
+ return tbl
150
+
151
+
48
152
  def _color_for(index: int) -> str:
49
153
  """Get a color from the palette by index.
50
154
 
@@ -137,64 +241,52 @@ class PlotNamespace:
137
241
  subplot_col = subplot_on.value if subplot_on is not None else None
138
242
 
139
243
  res = self.result if self.result.has_geometry else self.result.with_geometry()
140
- gdf = res.gdf()
141
244
 
142
- if column not in gdf.columns:
245
+ if column not in res.columns:
143
246
  raise ValueError(f"Column {column!r} not found in Result.")
144
247
 
145
- if subplot_col is not None and subplot_col not in gdf.columns:
248
+ if subplot_col is not None and subplot_col not in res.columns:
146
249
  raise ValueError(
147
250
  f"PlotNamespace.choropleth: subplot_on column {subplot_col!r} not found "
148
251
  "in Result. Ensure fortnightly data is materialised and temporal columns "
149
252
  "are present (they are added automatically by the materialisation layer)."
150
253
  )
151
254
 
152
- # Lonboard PolygonLayer only supports Polygon and MultiPolygon.
153
- # Extract polygon parts from GeometryCollections rather than dropping them.
154
- def _extract_polygons(geom: Any) -> Any:
155
- if geom is None:
156
- return None
157
- if geom.geom_type in ("Polygon", "MultiPolygon"):
158
- return geom
159
- if geom.geom_type == "GeometryCollection":
160
- from shapely.geometry import MultiPolygon
161
-
162
- polys = [
163
- g
164
- for g in getattr(geom, "geoms", [])
165
- if g.geom_type in ("Polygon", "MultiPolygon")
166
- ]
167
- if polys:
168
- return MultiPolygon(polys)
169
- return None
170
-
171
- gdf["geometry"] = gdf.geometry.apply(_extract_polygons)
172
- gdf = gdf[gdf.geometry.notna()].copy()
173
-
174
255
  import matplotlib as mpl
175
256
  import numpy as np
257
+ import polars as pl
258
+
259
+ geom_col = res.entity.geometry_col
260
+ extra = [column] + (res.key_cols or [])
261
+ if subplot_col is not None:
262
+ extra.append(subplot_col)
263
+
264
+ df = res.data
176
265
 
177
266
  if subplot_col is not None:
178
- # Render the most-recent/first unique value of subplot_on so that the
179
- # map is still useful. True multi-panel subplots are not supported by
180
- # Lonboard's single-Map API.
181
- unique_vals = sorted(gdf[subplot_col].to_list())
267
+ # Render most-recent unique value of subplot_on only.
268
+ # True multi-panel subplots not supported by Lonboard's single-Map API.
269
+ unique_vals = sorted(df[subplot_col].drop_nulls().unique().to_list())
182
270
  if unique_vals:
183
- gdf = gdf[gdf[subplot_col] == unique_vals[-1]].copy()
271
+ df = df.filter(pl.col(subplot_col) == unique_vals[-1])
184
272
 
185
- values = np.asarray(gdf[column])
273
+ # --- Direct Polars → GeoArrow → Lonboard path (zero Shapely, zero GeoPandas) ---
274
+ arrow_table = _wkb_to_arrow_table(df, geom_col, extra_cols=extra)
275
+
276
+ # Extract colormap values from the already-filtered arrow_table.
277
+ # arrow_table has the same row order as df post-filter, so indices align.
278
+ values = np.asarray(arrow_table.column(column).to_pylist())
186
279
  v_min, v_max = np.nanmin(values), np.nanmax(values)
187
- if v_max > v_min:
188
- norm_values = (values - v_min) / (v_max - v_min)
189
- else:
190
- norm_values = np.zeros_like(values)
280
+ norm_values = (
281
+ (values - v_min) / (v_max - v_min)
282
+ if v_max > v_min
283
+ else np.zeros_like(values)
284
+ )
191
285
 
192
286
  cmap = mpl.colormaps["plasma"]
193
287
 
194
- import geopandas as gpd
195
-
196
- layer = lonboard.PolygonLayer.from_geopandas(
197
- gpd.GeoDataFrame(gdf),
288
+ layer = lonboard.PolygonLayer(
289
+ arrow_table,
198
290
  get_fill_color=apply_continuous_cmap(norm_values, cmap),
199
291
  )
200
292
  return lonboard.Map(layers=[layer])
@@ -368,7 +460,9 @@ class PlotNamespace:
368
460
  unique_entities = entity_df[key_col].unique()
369
461
  if len(unique_entities) > top_n:
370
462
  entities = unique_entities.limit(top_n).to_list()
371
- entity_df = entity_df.filter(pl.col(key_col).is_in(entities))
463
+ entity_df = entity_df.join(
464
+ pl.DataFrame({key_col: entities}), on=key_col, how="inner"
465
+ )
372
466
  entity_pdf = entity_df.to_pandas()
373
467
 
374
468
  fig_entity = _make_fig(entity_pdf, f"Timeseries — {y_col} (Per Entity)")
@@ -420,7 +514,7 @@ class PlotNamespace:
420
514
  unique_entities = df[key_col].unique()
421
515
  if len(unique_entities) > top_n:
422
516
  entities = unique_entities.limit(top_n).to_list()
423
- df = df.filter(pl.col(key_col).is_in(entities))
517
+ df = df.join(pl.DataFrame({key_col: entities}), on=key_col, how="inner")
424
518
 
425
519
  df_pd = df.to_pandas()
426
520
 
@@ -491,7 +585,7 @@ class PlotNamespace:
491
585
  unique_entities = df[key_col].unique()
492
586
  if len(unique_entities) > top_n:
493
587
  entities = unique_entities.limit(top_n).to_list()
494
- df = df.filter(pl.col(key_col).is_in(entities))
588
+ df = df.join(pl.DataFrame({key_col: entities}), on=key_col, how="inner")
495
589
 
496
590
  df_pd = df.to_pandas()
497
591
 
@@ -148,11 +148,7 @@ class Result:
148
148
  geo_df = collect_lf(
149
149
  pl.scan_parquet(static_path)
150
150
  .select(key_cols + [geom_col])
151
- .filter(
152
- pl.col(key_cols[0]).is_in(self.data[key_cols[0]].to_list())
153
- if len(key_cols) == 1
154
- else pl.lit(True)
155
- )
151
+ .join(self.data.select(key_cols).unique().lazy(), on=key_cols, how="inner")
156
152
  )
157
153
 
158
154
  joined = self.data.join(geo_df, on=key_cols, how="left")
@@ -4,6 +4,18 @@ from typing import TYPE_CHECKING, Any
4
4
  if TYPE_CHECKING:
5
5
  from core_lens.base.result import Result
6
6
 
7
+ _DUCKDB_CONN = None
8
+
9
+
10
+ def _get_duckdb_conn() -> Any:
11
+ global _DUCKDB_CONN
12
+ if _DUCKDB_CONN is None:
13
+ import duckdb
14
+
15
+ _DUCKDB_CONN = duckdb.connect()
16
+ _DUCKDB_CONN.execute("INSTALL spatial; LOAD spatial;")
17
+ return _DUCKDB_CONN
18
+
7
19
 
8
20
  def parquet(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> None:
9
21
  """Export the result data to a standard Parquet file.
@@ -105,8 +117,6 @@ def geoparquet(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> Non
105
117
  "Call .with_geometry() first to join the static geometry column before exporting to geoparquet."
106
118
  )
107
119
 
108
- import duckdb
109
-
110
120
  df = result.df()
111
121
  geom_col = result.entity.geometry_col
112
122
 
@@ -119,8 +129,7 @@ def geoparquet(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> Non
119
129
 
120
130
  select_clause = ", ".join(cols)
121
131
 
122
- conn = duckdb.connect()
123
- conn.execute("INSTALL spatial; LOAD spatial;")
132
+ conn = _get_duckdb_conn()
124
133
 
125
134
  # We must escape single quotes in path if any exist.
126
135
  path_str = str(path).replace("'", "''")
@@ -173,8 +182,6 @@ def geojson(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> None:
173
182
  "Call .with_geometry() first to join the static geometry column before exporting to geojson."
174
183
  )
175
184
 
176
- import duckdb
177
-
178
185
  df = result.df()
179
186
  geom_col = result.entity.geometry_col
180
187
 
@@ -187,8 +194,7 @@ def geojson(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> None:
187
194
 
188
195
  select_clause = ", ".join(cols)
189
196
 
190
- conn = duckdb.connect()
191
- conn.execute("INSTALL spatial; LOAD spatial;")
197
+ conn = _get_duckdb_conn()
192
198
 
193
199
  path_str = str(path).replace("'", "''")
194
200
 
@@ -102,16 +102,10 @@ def scan_with_key_filter(
102
102
  """
103
103
  lf = pl.scan_parquet(path)
104
104
 
105
- if len(key_cols) == 1:
106
- key = key_cols[0]
107
- values = key_values[key].to_list()
108
- lf = lf.filter(pl.col(key).is_in(values))
109
- else:
110
- # Composite key: filter each column independently. A small over-selection
111
- # is acceptable because the subsequent join at collect time is exact.
112
- for key in key_cols:
113
- values = key_values[key].to_list()
114
- lf = lf.filter(pl.col(key).is_in(values))
105
+ # Use an inner join to filter the parquet file down to the exact requested keys.
106
+ # This avoids building a massive literal expression tree (which consumes gigabytes
107
+ # of RAM) that occurs when passing large lists to pl.col().is_in().
108
+ lf = lf.join(key_values.lazy(), on=key_cols, how="inner")
115
109
 
116
110
  if time_expr is not None:
117
111
  lf = lf.filter(time_expr)
@@ -6,11 +6,10 @@ import pathlib
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
8
  import polars as pl
9
- import shapely.wkb as swkb
10
- import shapely.wkt as swkt
9
+ import shapely
11
10
 
12
11
  if TYPE_CHECKING:
13
- import shapely
12
+ pass
14
13
 
15
14
 
16
15
  def resolve_path(path: str) -> str:
@@ -81,19 +80,27 @@ def build_bbox_index(
81
80
  df = pl.read_parquet(static_path, columns=cols_to_read)
82
81
  geom_series = df[geometry_col]
83
82
 
83
+ geom_array = geom_series.to_numpy()
84
+
84
85
  if geometry_type == "wkb":
85
- geoms = [swkb.loads(v) for v in geom_series.to_list()]
86
+ geoms = shapely.from_wkb(geom_array)
86
87
  else:
87
- geoms = [swkt.loads(v) for v in geom_series.to_list()]
88
-
89
- bounds = [g.bounds for g in geoms]
90
- minx, miny, maxx, maxy = zip(*bounds) if bounds else ([], [], [], [])
88
+ geoms = shapely.from_wkt(geom_array)
89
+
90
+ if len(geoms) > 0:
91
+ bnds = shapely.bounds(geoms)
92
+ return df.select(key_cols).with_columns(
93
+ pl.Series("minx", bnds[:, 0], dtype=pl.Float64),
94
+ pl.Series("miny", bnds[:, 1], dtype=pl.Float64),
95
+ pl.Series("maxx", bnds[:, 2], dtype=pl.Float64),
96
+ pl.Series("maxy", bnds[:, 3], dtype=pl.Float64),
97
+ )
91
98
 
92
99
  return df.select(key_cols).with_columns(
93
- pl.Series("minx", list(minx), dtype=pl.Float64),
94
- pl.Series("miny", list(miny), dtype=pl.Float64),
95
- pl.Series("maxx", list(maxx), dtype=pl.Float64),
96
- pl.Series("maxy", list(maxy), dtype=pl.Float64),
100
+ pl.Series("minx", [], dtype=pl.Float64),
101
+ pl.Series("miny", [], dtype=pl.Float64),
102
+ pl.Series("maxx", [], dtype=pl.Float64),
103
+ pl.Series("maxy", [], dtype=pl.Float64),
97
104
  )
98
105
 
99
106
 
@@ -175,23 +182,19 @@ def exact_spatial_filter(
175
182
  if candidates.is_empty():
176
183
  return candidates.select(key_cols)
177
184
 
178
- # Build a filter expression to read only the candidate rows.
179
- full_df = pl.read_parquet(static_path, columns=key_cols + [geometry_col])
180
-
181
- # Filter to candidate keys.
182
- if len(key_cols) == 1:
183
- key = key_cols[0]
184
- candidate_keys = candidates[key].to_list()
185
- full_df = full_df.filter(pl.col(key).is_in(candidate_keys))
186
- else:
187
- # Composite key: join on all key columns.
188
- full_df = full_df.join(candidates.select(key_cols), on=key_cols, how="inner")
185
+ # Build a lazy scan and push down an inner join to load only required geometries.
186
+ full_df = (
187
+ pl.scan_parquet(static_path)
188
+ .select(key_cols + [geometry_col])
189
+ .join(candidates.select(key_cols).lazy(), on=key_cols, how="inner")
190
+ .collect()
191
+ )
189
192
 
190
- geom_series = full_df[geometry_col].to_list()
193
+ geom_array = full_df[geometry_col].to_numpy()
191
194
  if geometry_type == "wkb":
192
- geoms = [swkb.loads(v) for v in geom_series]
195
+ geoms = shapely.from_wkb(geom_array)
193
196
  else:
194
- geoms = [swkt.loads(v) for v in geom_series]
197
+ geoms = shapely.from_wkt(geom_array)
195
198
 
196
199
  if relationship == "centroid":
197
200
  # Centroid mode: entity centroid must lie within the AoI geometry.
@@ -209,10 +212,10 @@ def exact_spatial_filter(
209
212
  hit_indices = []
210
213
  for i in intersect_idx:
211
214
  entity_geom = geoms[i]
212
- entity_area = entity_geom.area
215
+ entity_area = entity_geom.area # pyright: ignore[reportAttributeAccessIssue]
213
216
  if entity_area == 0.0:
214
217
  continue
215
- inter_area = entity_geom.intersection(aoi_geometry).area
218
+ inter_area = entity_geom.intersection(aoi_geometry).area # pyright: ignore[reportAttributeAccessIssue]
216
219
  if inter_area / entity_area >= threshold:
217
220
  hit_indices.append(i)
218
221
 
@@ -267,15 +270,24 @@ def execute_spatial_join(
267
270
  other_df = pl.read_parquet(other_static, columns=other_cols)
268
271
 
269
272
  # Decode other geometries.
270
- other_geom_series = other_df[other_geom_col].to_list()
273
+ other_geom_array = other_df[other_geom_col].to_numpy()
271
274
  if other_geom_type == "wkb":
272
- other_geoms = [swkb.loads(v) for v in other_geom_series]
275
+ other_geoms = shapely.from_wkb(other_geom_array)
273
276
  else:
274
- other_geoms = [swkt.loads(v) for v in other_geom_series]
277
+ other_geoms = shapely.from_wkt(other_geom_array)
275
278
 
276
279
  # Build STRtree from other entity geometries.
277
280
  other_tree = shapely.STRtree(other_geoms)
278
- other_df_np = other_df.to_pandas() # for row-level indexing during agg
281
+
282
+ # Only convert the columns we actually need to aggregate to Pandas, avoiding
283
+ # a massive memory copy of the WKB geometry strings and unused keys.
284
+ agg_cols_only = [
285
+ c for c in agg.keys() if c not in ("count", "area") and c in other_df.columns
286
+ ]
287
+ if agg_cols_only:
288
+ other_df_np = other_df.select(agg_cols_only).to_pandas()
289
+ else:
290
+ other_df_np = None
279
291
 
280
292
  # Decode primary geometries.
281
293
  if primary_geom_col not in primary_df.columns:
@@ -285,18 +297,28 @@ def execute_spatial_join(
285
297
  "with_geometry() first."
286
298
  )
287
299
 
288
- primary_geom_series = primary_df[primary_geom_col].to_list()
300
+ primary_geom_array = primary_df[primary_geom_col].to_numpy()
289
301
  if primary_geom_type == "wkb":
290
- primary_geoms = [swkb.loads(v) for v in primary_geom_series]
302
+ primary_geoms = shapely.from_wkb(primary_geom_array)
291
303
  else:
292
- primary_geoms = [swkt.loads(v) for v in primary_geom_series]
304
+ primary_geoms = shapely.from_wkt(primary_geom_array)
293
305
 
294
306
  # For each primary entity, find overlapping other entities and aggregate.
295
307
  result_rows: list[dict[str, Any]] = []
296
308
  primary_keys = primary_df.select(primary_key_cols).to_dicts()
297
309
 
310
+ # Vectorized STRtree query over all primary geometries at once
311
+ # Returns a 2D array of shape (2, N): [[primary_indices], [other_indices]]
312
+ idx_pairs = other_tree.query(primary_geoms, predicate="intersects")
313
+
314
+ from collections import defaultdict
315
+
316
+ hits_by_primary = defaultdict(list)
317
+ for p_idx, o_idx in zip(idx_pairs[0], idx_pairs[1]):
318
+ hits_by_primary[p_idx].append(o_idx)
319
+
298
320
  for i, (pgeom, pkey) in enumerate(zip(primary_geoms, primary_keys)):
299
- hit_idx = other_tree.query(pgeom, predicate="intersects").tolist()
321
+ hit_idx = hits_by_primary.get(i, [])
300
322
  row: dict[str, Any] = dict(pkey)
301
323
 
302
324
  if not hit_idx:
@@ -306,7 +328,10 @@ def execute_spatial_join(
306
328
  result_rows.append(row)
307
329
  continue
308
330
 
309
- matched_other = other_df_np.iloc[hit_idx]
331
+ if other_df_np is not None:
332
+ matched_other = other_df_np.iloc[hit_idx]
333
+ else:
334
+ matched_other = None
310
335
 
311
336
  for col, fn in agg.items():
312
337
  out_col = f"{other_entity_name}_{col}"
@@ -314,10 +339,10 @@ def execute_spatial_join(
314
339
  row[out_col] = len(hit_idx)
315
340
  elif fn == "area":
316
341
  # Sum of intersection areas (geographic, approx).
317
- total = sum(pgeom.intersection(other_geoms[j]).area for j in hit_idx)
342
+ total = sum(pgeom.intersection(other_geoms[j]).area for j in hit_idx) # pyright: ignore[reportAttributeAccessIssue]
318
343
  row[out_col] = total
319
- elif col in matched_other.columns:
320
- vals = matched_other[col].dropna().values
344
+ elif matched_other is not None and col in matched_other.columns:
345
+ vals = matched_other[col].dropna()
321
346
  if fn == "mean":
322
347
  row[out_col] = float(vals.mean()) if len(vals) else None
323
348
  elif fn == "sum":
@@ -554,6 +554,7 @@ core = [
554
554
  full = [
555
555
  { name = "bokeh" },
556
556
  { name = "duckdb" },
557
+ { name = "geoarrow-rust-core" },
557
558
  { name = "geopandas" },
558
559
  { name = "lonboard" },
559
560
  { name = "matplotlib" },
@@ -570,6 +571,7 @@ full-gpu = [
570
571
  { name = "cudf-cu13" },
571
572
  { name = "cudf-polars-cu13" },
572
573
  { name = "duckdb" },
574
+ { name = "geoarrow-rust-core" },
573
575
  { name = "geopandas" },
574
576
  { name = "lonboard" },
575
577
  { name = "matplotlib" },
@@ -606,6 +608,7 @@ stats = [
606
608
  ]
607
609
  viz = [
608
610
  { name = "bokeh" },
611
+ { name = "geoarrow-rust-core" },
609
612
  { name = "lonboard" },
610
613
  { name = "matplotlib" },
611
614
  { name = "polars" },
@@ -653,6 +656,9 @@ requires-dist = [
653
656
  { name = "duckdb", marker = "extra == 'full'", specifier = ">=1.5.3,<2" },
654
657
  { name = "duckdb", marker = "extra == 'full-gpu'", specifier = ">=1.5.3,<2" },
655
658
  { name = "duckdb", marker = "extra == 'spatial'", specifier = ">=1.5.3,<2" },
659
+ { name = "geoarrow-rust-core", marker = "extra == 'full'", specifier = ">=0.3.0" },
660
+ { name = "geoarrow-rust-core", marker = "extra == 'full-gpu'", specifier = ">=0.3.0" },
661
+ { name = "geoarrow-rust-core", marker = "extra == 'viz'", specifier = ">=0.3.0" },
656
662
  { name = "geopandas", marker = "extra == 'full'", specifier = ">=1.1.3,<2" },
657
663
  { name = "geopandas", marker = "extra == 'full-gpu'", specifier = ">=1.1.3,<2" },
658
664
  { name = "geopandas", marker = "extra == 'spatial'", specifier = ">=1.1.3,<2" },
@@ -1,29 +0,0 @@
1
- import polars as pl
2
- from core_lens import AoI
3
- from core_lens.base.entity import BaseEntity
4
-
5
- from core_lens.entities.waterbody import WaterbodyEntity
6
-
7
- def main():
8
- # Register the entity
9
- AoI.register(WaterbodyEntity)
10
-
11
- # Init AoI with a bounding box for India (or you can adjust bbox)
12
- DATA_ROOT = "data/"
13
- aoi = AoI(DATA_ROOT, bbox=(68.0, 6.0, 98.0, 38.0))
14
-
15
- # Materialise the static waterbodies data
16
- print("Loading waterbodies...")
17
- wb_result = aoi.waterbody.static
18
-
19
- # Plot using the area column for choropleth colors
20
- print("Plotting waterbodies...")
21
- choropleth_map = wb_result.plot.choropleth("area_ha")
22
-
23
- # Save the map to HTML
24
- output_file = "wb_map.html"
25
- choropleth_map.to_html(output_file)
26
- print(f"Saved waterbodies map to {output_file}")
27
-
28
- if __name__ == "__main__":
29
- main()
@@ -1,11 +0,0 @@
1
- tehsil String
2
- district String
3
- state String
4
- area_in_ha Float64
5
- wb_id String
6
- ba_name String
7
- year Int32
8
- area_ha Float64
9
- kharif Float64
10
- karif_rabi Float64
11
- kharif_rabi_zaid Float64
@@ -1,11 +0,0 @@
1
- tehsil String
2
- district String
3
- state String
4
- area_in_ha Float64
5
- wb_id String
6
- ba_name String
7
- year Int32
8
- area_ha Float64
9
- kharif Float64
10
- karif_rabi Float64
11
- kharif_rabi_zaid Float64
File without changes
File without changes
File without changes