core-lens 0.1.dev105__tar.gz → 0.1.dev108__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.dev105 → core_lens-0.1.dev108}/PKG-INFO +4 -1
  2. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/pyproject.toml +4 -0
  3. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/_version.py +2 -2
  4. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/aoi.py +17 -29
  5. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/namespaces/plot.py +131 -39
  6. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/waterbody.py +0 -4
  7. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/export/formats.py +14 -8
  8. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/polars_utils.py +4 -10
  9. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/spatial.py +58 -29
  10. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/uv.lock +6 -0
  11. core_lens-0.1.dev105/plot_wb.py +0 -29
  12. core_lens-0.1.dev105/wb_annual.txt +0 -11
  13. core_lens-0.1.dev105/wb_static.txt +0 -11
  14. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/pull_request_template.md +0 -0
  15. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/ci.yml +0 -0
  16. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/gh-pages.yml +0 -0
  17. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/pre-release.yml +0 -0
  18. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/release.yml +0 -0
  19. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.gitignore +0 -0
  20. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.gitmessage +0 -0
  21. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.pre-commit-config.yaml +0 -0
  22. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.python-version +0 -0
  23. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/CONTRIBUTING.md +0 -0
  24. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/LICENSE +0 -0
  25. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/README.md +0 -0
  26. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/SKILLS.md +0 -0
  27. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/Makefile +0 -0
  28. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/make.bat +0 -0
  29. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/concepts.md +0 -0
  30. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/conf.py +0 -0
  31. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/index.rst +0 -0
  32. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/intro.md +0 -0
  33. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/plots.md +0 -0
  34. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/plugins.md +0 -0
  35. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/queries.md +0 -0
  36. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/quickstart.md +0 -0
  37. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/stats.md +0 -0
  38. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/examples/demo_mws.py +0 -0
  39. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/examples/demo_tehsil.py +0 -0
  40. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/hooks/mypy.sh +0 -0
  41. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/hooks/no-parquet-outside-fixtures.sh +0 -0
  42. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/hooks/pytest.sh +0 -0
  43. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/__init__.py +0 -0
  44. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/__main__.py +0 -0
  45. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/__init__.py +0 -0
  46. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/entity.py +0 -0
  47. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/namespaces/__init__.py +0 -0
  48. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/namespaces/stats.py +0 -0
  49. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/result.py +0 -0
  50. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/view.py +0 -0
  51. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/__init__.py +0 -0
  52. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/mws.py +0 -0
  53. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/tehsil.py +0 -0
  54. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/export/__init__.py +0 -0
  55. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/py.typed +0 -0
  56. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/schema/__init__.py +0 -0
  57. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/schema/detection.py +0 -0
  58. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/schema/profile.py +0 -0
  59. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/__init__.py +0 -0
  60. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/season.py +0 -0
  61. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/fixtures/generate_fixtures.py +0 -0
  62. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/conftest.py +0 -0
  63. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_aoi.py +0 -0
  64. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_entities.py +0 -0
  65. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_entity.py +0 -0
  66. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_export.py +0 -0
  67. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_main.py +0 -0
  68. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_plot.py +0 -0
  69. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_polars_utils.py +0 -0
  70. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_profile.py +0 -0
  71. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_result.py +0 -0
  72. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_schema_detection.py +0 -0
  73. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_schema_profile.py +0 -0
  74. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_season.py +0 -0
  75. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_season_config.py +0 -0
  76. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_spatial.py +0 -0
  77. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_stats.py +0 -0
  78. {core_lens-0.1.dev105 → core_lens-0.1.dev108}/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.dev105
3
+ Version: 0.1.dev108
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.dev105'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev105')
21
+ __version__ = version = '0.1.dev108'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev108')
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.
@@ -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
176
258
 
259
+ geom_col = res.entity.geometry_col
260
+ extra = [column] + (res.key_cols or [])
177
261
  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())
262
+ extra.append(subplot_col)
263
+
264
+ df = res.data
265
+
266
+ if subplot_col is not None:
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])
272
+
273
+ # --- Direct Polars → GeoArrow → Lonboard path (zero Shapely, zero GeoPandas) ---
274
+ arrow_table = _wkb_to_arrow_table(df, geom_col, extra_cols=extra)
184
275
 
185
- values = np.asarray(gdf[column])
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])
@@ -26,7 +26,3 @@ class WaterbodyEntity(BaseEntity):
26
26
  @property
27
27
  def annual_path(self) -> str | None:
28
28
  return "waterbodies/annual"
29
-
30
- @property
31
- def fortnightly_path(self) -> str | None:
32
- return "waterbodies/fortnightly"
@@ -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
 
@@ -187,11 +194,11 @@ def exact_spatial_filter(
187
194
  # Composite key: join on all key columns.
188
195
  full_df = full_df.join(candidates.select(key_cols), on=key_cols, how="inner")
189
196
 
190
- geom_series = full_df[geometry_col].to_list()
197
+ geom_array = full_df[geometry_col].to_numpy()
191
198
  if geometry_type == "wkb":
192
- geoms = [swkb.loads(v) for v in geom_series]
199
+ geoms = shapely.from_wkb(geom_array)
193
200
  else:
194
- geoms = [swkt.loads(v) for v in geom_series]
201
+ geoms = shapely.from_wkt(geom_array)
195
202
 
196
203
  if relationship == "centroid":
197
204
  # Centroid mode: entity centroid must lie within the AoI geometry.
@@ -209,10 +216,10 @@ def exact_spatial_filter(
209
216
  hit_indices = []
210
217
  for i in intersect_idx:
211
218
  entity_geom = geoms[i]
212
- entity_area = entity_geom.area
219
+ entity_area = entity_geom.area # pyright: ignore[reportAttributeAccessIssue]
213
220
  if entity_area == 0.0:
214
221
  continue
215
- inter_area = entity_geom.intersection(aoi_geometry).area
222
+ inter_area = entity_geom.intersection(aoi_geometry).area # pyright: ignore[reportAttributeAccessIssue]
216
223
  if inter_area / entity_area >= threshold:
217
224
  hit_indices.append(i)
218
225
 
@@ -267,15 +274,24 @@ def execute_spatial_join(
267
274
  other_df = pl.read_parquet(other_static, columns=other_cols)
268
275
 
269
276
  # Decode other geometries.
270
- other_geom_series = other_df[other_geom_col].to_list()
277
+ other_geom_array = other_df[other_geom_col].to_numpy()
271
278
  if other_geom_type == "wkb":
272
- other_geoms = [swkb.loads(v) for v in other_geom_series]
279
+ other_geoms = shapely.from_wkb(other_geom_array)
273
280
  else:
274
- other_geoms = [swkt.loads(v) for v in other_geom_series]
281
+ other_geoms = shapely.from_wkt(other_geom_array)
275
282
 
276
283
  # Build STRtree from other entity geometries.
277
284
  other_tree = shapely.STRtree(other_geoms)
278
- other_df_np = other_df.to_pandas() # for row-level indexing during agg
285
+
286
+ # Only convert the columns we actually need to aggregate to Pandas, avoiding
287
+ # a massive memory copy of the WKB geometry strings and unused keys.
288
+ agg_cols_only = [
289
+ c for c in agg.keys() if c not in ("count", "area") and c in other_df.columns
290
+ ]
291
+ if agg_cols_only:
292
+ other_df_np = other_df.select(agg_cols_only).to_pandas()
293
+ else:
294
+ other_df_np = None
279
295
 
280
296
  # Decode primary geometries.
281
297
  if primary_geom_col not in primary_df.columns:
@@ -285,18 +301,28 @@ def execute_spatial_join(
285
301
  "with_geometry() first."
286
302
  )
287
303
 
288
- primary_geom_series = primary_df[primary_geom_col].to_list()
304
+ primary_geom_array = primary_df[primary_geom_col].to_numpy()
289
305
  if primary_geom_type == "wkb":
290
- primary_geoms = [swkb.loads(v) for v in primary_geom_series]
306
+ primary_geoms = shapely.from_wkb(primary_geom_array)
291
307
  else:
292
- primary_geoms = [swkt.loads(v) for v in primary_geom_series]
308
+ primary_geoms = shapely.from_wkt(primary_geom_array)
293
309
 
294
310
  # For each primary entity, find overlapping other entities and aggregate.
295
311
  result_rows: list[dict[str, Any]] = []
296
312
  primary_keys = primary_df.select(primary_key_cols).to_dicts()
297
313
 
314
+ # Vectorized STRtree query over all primary geometries at once
315
+ # Returns a 2D array of shape (2, N): [[primary_indices], [other_indices]]
316
+ idx_pairs = other_tree.query(primary_geoms, predicate="intersects")
317
+
318
+ from collections import defaultdict
319
+
320
+ hits_by_primary = defaultdict(list)
321
+ for p_idx, o_idx in zip(idx_pairs[0], idx_pairs[1]):
322
+ hits_by_primary[p_idx].append(o_idx)
323
+
298
324
  for i, (pgeom, pkey) in enumerate(zip(primary_geoms, primary_keys)):
299
- hit_idx = other_tree.query(pgeom, predicate="intersects").tolist()
325
+ hit_idx = hits_by_primary.get(i, [])
300
326
  row: dict[str, Any] = dict(pkey)
301
327
 
302
328
  if not hit_idx:
@@ -306,7 +332,10 @@ def execute_spatial_join(
306
332
  result_rows.append(row)
307
333
  continue
308
334
 
309
- matched_other = other_df_np.iloc[hit_idx]
335
+ if other_df_np is not None:
336
+ matched_other = other_df_np.iloc[hit_idx]
337
+ else:
338
+ matched_other = None
310
339
 
311
340
  for col, fn in agg.items():
312
341
  out_col = f"{other_entity_name}_{col}"
@@ -314,10 +343,10 @@ def execute_spatial_join(
314
343
  row[out_col] = len(hit_idx)
315
344
  elif fn == "area":
316
345
  # Sum of intersection areas (geographic, approx).
317
- total = sum(pgeom.intersection(other_geoms[j]).area for j in hit_idx)
346
+ total = sum(pgeom.intersection(other_geoms[j]).area for j in hit_idx) # pyright: ignore[reportAttributeAccessIssue]
318
347
  row[out_col] = total
319
- elif col in matched_other.columns:
320
- vals = matched_other[col].dropna().values
348
+ elif matched_other is not None and col in matched_other.columns:
349
+ vals = matched_other[col].dropna()
321
350
  if fn == "mean":
322
351
  row[out_col] = float(vals.mean()) if len(vals) else None
323
352
  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