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.
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/PKG-INFO +4 -1
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/pyproject.toml +4 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/aoi.py +17 -29
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/namespaces/plot.py +131 -39
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/waterbody.py +0 -4
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/export/formats.py +14 -8
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/polars_utils.py +4 -10
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/spatial.py +58 -29
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/uv.lock +6 -0
- core_lens-0.1.dev105/plot_wb.py +0 -29
- core_lens-0.1.dev105/wb_annual.txt +0 -11
- core_lens-0.1.dev105/wb_static.txt +0 -11
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.gitignore +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.gitmessage +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/.python-version +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/LICENSE +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/README.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/SKILLS.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/Makefile +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/make.bat +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/namespaces/stats.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/base/view.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev105 → core_lens-0.1.dev108}/tests/unit/test_stats.py +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, '
|
|
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, "
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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])
|
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
|
10
|
-
import shapely.wkt as swkt
|
|
9
|
+
import shapely
|
|
11
10
|
|
|
12
11
|
if TYPE_CHECKING:
|
|
13
|
-
|
|
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 =
|
|
86
|
+
geoms = shapely.from_wkb(geom_array)
|
|
86
87
|
else:
|
|
87
|
-
geoms =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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",
|
|
94
|
-
pl.Series("miny",
|
|
95
|
-
pl.Series("maxx",
|
|
96
|
-
pl.Series("maxy",
|
|
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
|
-
|
|
197
|
+
geom_array = full_df[geometry_col].to_numpy()
|
|
191
198
|
if geometry_type == "wkb":
|
|
192
|
-
geoms =
|
|
199
|
+
geoms = shapely.from_wkb(geom_array)
|
|
193
200
|
else:
|
|
194
|
-
geoms =
|
|
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
|
-
|
|
277
|
+
other_geom_array = other_df[other_geom_col].to_numpy()
|
|
271
278
|
if other_geom_type == "wkb":
|
|
272
|
-
other_geoms =
|
|
279
|
+
other_geoms = shapely.from_wkb(other_geom_array)
|
|
273
280
|
else:
|
|
274
|
-
other_geoms =
|
|
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
|
-
|
|
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
|
-
|
|
304
|
+
primary_geom_array = primary_df[primary_geom_col].to_numpy()
|
|
289
305
|
if primary_geom_type == "wkb":
|
|
290
|
-
primary_geoms =
|
|
306
|
+
primary_geoms = shapely.from_wkb(primary_geom_array)
|
|
291
307
|
else:
|
|
292
|
-
primary_geoms =
|
|
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 =
|
|
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
|
-
|
|
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()
|
|
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" },
|
core_lens-0.1.dev105/plot_wb.py
DELETED
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|