core-lens 0.1.dev175__tar.gz → 0.1.dev178__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.dev175 → core_lens-0.1.dev178}/PKG-INFO +1 -1
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/aoi.py +2 -1
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/spatial.py +106 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_spatial.py +177 -0
- core_lens-0.1.dev178/usage.md +182 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/uv.lock +190 -190
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.gitignore +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.gitmessage +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.python-version +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/LICENSE +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/README.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/SKILLS.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/Makefile +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/make.bat +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/export.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/pyproject.toml +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/namespaces/stats.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/view.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev175 → core_lens-0.1.dev178}/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.dev178
|
|
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
|
|
@@ -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.dev178'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 'dev178')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -284,7 +284,8 @@ class AoI:
|
|
|
284
284
|
|
|
285
285
|
**Registration** must happen before any ``AoI`` is constructed::
|
|
286
286
|
|
|
287
|
-
from core_lens import AoI
|
|
287
|
+
from core_lens import AoI
|
|
288
|
+
from core_lens.entities import MWSEntity, TehsilEntity
|
|
288
289
|
|
|
289
290
|
AoI.register(MWSEntity)
|
|
290
291
|
AoI.register(TehsilEntity)
|
|
@@ -607,3 +607,109 @@ def execute_spatial_join(
|
|
|
607
607
|
on=primary_key_cols,
|
|
608
608
|
how="left",
|
|
609
609
|
)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def point_in_entities(
|
|
613
|
+
latlon: dict[str, float],
|
|
614
|
+
entities: list[Any],
|
|
615
|
+
) -> dict[str, Any | None]:
|
|
616
|
+
"""Return the entity-id in which a lat/lon point falls, per entity.
|
|
617
|
+
|
|
618
|
+
For each entity, performs a two-phase lookup:
|
|
619
|
+
|
|
620
|
+
1. **Bbox pre-filter** — eliminates entities whose bounding box doesn't
|
|
621
|
+
contain the point using the in-memory ``_index`` (no I/O).
|
|
622
|
+
2. **Exact containment** — loads geometry only for bbox candidates and
|
|
623
|
+
runs a vectorised Shapely ``contains`` test.
|
|
624
|
+
|
|
625
|
+
The function is designed for repeated hot-path calls; it relies on
|
|
626
|
+
``entity._index`` and ``entity.geometry_lazy`` which are both
|
|
627
|
+
process-level cached after first access.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
latlon (dict[str, float]): Point coordinates with keys ``"lat"`` and
|
|
631
|
+
``"lon"`` (or ``"lng"`` as an alias for longitude).
|
|
632
|
+
entities (list[BaseEntity]): List of entity instances to test against.
|
|
633
|
+
Each must expose ``_index``, ``key_cols``, ``schema_profile``,
|
|
634
|
+
``_resolve``, and ``static_path``.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
dict[str, Any | None]: Mapping of ``entity_class_name → entity_id``
|
|
638
|
+
(the value of the first ``key_col``). ``None`` if the point doesn't
|
|
639
|
+
fall inside any geometry of that entity.
|
|
640
|
+
|
|
641
|
+
Raises:
|
|
642
|
+
KeyError: If neither ``"lon"`` nor ``"lng"`` key exists in ``latlon``.
|
|
643
|
+
|
|
644
|
+
Example::
|
|
645
|
+
|
|
646
|
+
result = point_in_entities(
|
|
647
|
+
{"lat": 28.6139, "lon": 77.2090},
|
|
648
|
+
[district_entity, block_entity],
|
|
649
|
+
)
|
|
650
|
+
# {"DistrictEntity": "DEL_001", "BlockEntity": None}
|
|
651
|
+
|
|
652
|
+
"""
|
|
653
|
+
lat: float = latlon["lat"]
|
|
654
|
+
lon: float = latlon.get("lon", latlon.get("lng")) # type: ignore[assignment]
|
|
655
|
+
|
|
656
|
+
# Shapely Point — created once, reused across all entities.
|
|
657
|
+
pt = shapely.Point(lon, lat)
|
|
658
|
+
|
|
659
|
+
result: dict[str, Any | None] = {}
|
|
660
|
+
|
|
661
|
+
for entity in entities:
|
|
662
|
+
entity_name = type(entity).__name__
|
|
663
|
+
|
|
664
|
+
# Phase 1: bbox pre-filter (pure in-memory, no I/O) ---------------
|
|
665
|
+
index_df = (
|
|
666
|
+
entity._index
|
|
667
|
+
) # pl.DataFrame with (key_cols…, minx, miny, maxx, maxy)
|
|
668
|
+
candidates = index_df.filter(
|
|
669
|
+
(pl.col("minx") <= lon)
|
|
670
|
+
& (pl.col("maxx") >= lon)
|
|
671
|
+
& (pl.col("miny") <= lat)
|
|
672
|
+
& (pl.col("maxy") >= lat)
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
if candidates.is_empty():
|
|
676
|
+
result[entity_name] = None
|
|
677
|
+
continue
|
|
678
|
+
|
|
679
|
+
# Phase 2: exact containment (load only candidate geometries) ------
|
|
680
|
+
profile = entity.schema_profile
|
|
681
|
+
geom_col = profile.geometry_col
|
|
682
|
+
geom_type = profile.geometry_type
|
|
683
|
+
key_cols: list[str] = entity.key_cols
|
|
684
|
+
static_path: str = entity._resolve(entity.static_path)
|
|
685
|
+
|
|
686
|
+
geom_df = (
|
|
687
|
+
pl.scan_parquet(static_path)
|
|
688
|
+
.select(key_cols + [geom_col])
|
|
689
|
+
.join(candidates.select(key_cols).lazy(), on=key_cols, how="semi")
|
|
690
|
+
.collect()
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
if geom_df.is_empty():
|
|
694
|
+
result[entity_name] = None
|
|
695
|
+
continue
|
|
696
|
+
|
|
697
|
+
raw = geom_df[geom_col].to_numpy()
|
|
698
|
+
if geom_type == "wkb":
|
|
699
|
+
geoms = shapely.from_wkb(raw)
|
|
700
|
+
else:
|
|
701
|
+
geoms = shapely.from_wkt(raw)
|
|
702
|
+
|
|
703
|
+
# Vectorised contains — after bbox pre-filter candidates are O(1~10),
|
|
704
|
+
# so tree construction would cost more than the query itself.
|
|
705
|
+
mask = shapely.contains(geoms, pt) # C-level, no Python loop
|
|
706
|
+
hit_indices = np.where(mask)[0]
|
|
707
|
+
|
|
708
|
+
if len(hit_indices) == 0:
|
|
709
|
+
result[entity_name] = None
|
|
710
|
+
else:
|
|
711
|
+
# Return first key_col value of the first match.
|
|
712
|
+
primary_key = key_cols[0]
|
|
713
|
+
result[entity_name] = geom_df[primary_key][int(hit_indices[0])]
|
|
714
|
+
|
|
715
|
+
return result
|
|
@@ -239,3 +239,180 @@ def test_execute_spatial_join_no_match_and_agg(tmp_path: Any) -> None:
|
|
|
239
239
|
assert res["other_val"][1] == 10.0 # Match
|
|
240
240
|
assert res["other_val2"][1] == 10.0
|
|
241
241
|
assert res["other_val3"][1] == 10.0
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _make_pie_entity(tmp_path: Any, name: str, polygons: list[tuple[str, str]]) -> Any:
|
|
245
|
+
"""Build a minimal entity stub for point_in_entities tests.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
tmp_path: pytest tmp_path fixture.
|
|
249
|
+
name: Class name (used as entity key in result dict).
|
|
250
|
+
polygons: List of (id, wkt_polygon_string) tuples.
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Entity-like object with _index, key_cols, schema_profile, _resolve, static_path.
|
|
254
|
+
|
|
255
|
+
"""
|
|
256
|
+
from core_lens.schema.profile import SchemaProfile
|
|
257
|
+
|
|
258
|
+
ids = [p[0] for p in polygons]
|
|
259
|
+
wkts = [p[1] for p in polygons]
|
|
260
|
+
|
|
261
|
+
p = tmp_path / f"{name}.parquet"
|
|
262
|
+
pl.DataFrame({"entity_id": ids, "geom": wkts}).write_parquet(p)
|
|
263
|
+
|
|
264
|
+
# Build bbox index manually from WKT bounds.
|
|
265
|
+
import shapely
|
|
266
|
+
|
|
267
|
+
geoms = shapely.from_wkt(wkts)
|
|
268
|
+
bnds = shapely.bounds(geoms) if len(geoms) else None
|
|
269
|
+
if bnds is not None and len(bnds):
|
|
270
|
+
index = pl.DataFrame(
|
|
271
|
+
{
|
|
272
|
+
"entity_id": ids,
|
|
273
|
+
"minx": bnds[:, 0].tolist(),
|
|
274
|
+
"miny": bnds[:, 1].tolist(),
|
|
275
|
+
"maxx": bnds[:, 2].tolist(),
|
|
276
|
+
"maxy": bnds[:, 3].tolist(),
|
|
277
|
+
}
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
index = pl.DataFrame(
|
|
281
|
+
schema={
|
|
282
|
+
"entity_id": pl.Utf8,
|
|
283
|
+
"minx": pl.Float64,
|
|
284
|
+
"miny": pl.Float64,
|
|
285
|
+
"maxx": pl.Float64,
|
|
286
|
+
"maxy": pl.Float64,
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
profile = SchemaProfile(
|
|
291
|
+
key_cols=["entity_id"],
|
|
292
|
+
geometry_col="geom",
|
|
293
|
+
geometry_type="wkt",
|
|
294
|
+
annual_time_col=None,
|
|
295
|
+
sub_annual_time_col=None,
|
|
296
|
+
bbox_cols=None,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
static_str = str(p)
|
|
300
|
+
|
|
301
|
+
class _Entity:
|
|
302
|
+
key_cols = ["entity_id"]
|
|
303
|
+
static_path = static_str
|
|
304
|
+
schema_profile = profile
|
|
305
|
+
_index = index
|
|
306
|
+
|
|
307
|
+
def _resolve(self, path: str) -> str:
|
|
308
|
+
return path
|
|
309
|
+
|
|
310
|
+
_Entity.__name__ = name
|
|
311
|
+
return _Entity()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def test_point_in_entities_hit(tmp_path: Any) -> None:
|
|
315
|
+
"""Point inside a polygon returns its id."""
|
|
316
|
+
from core_lens.utils.spatial import point_in_entities
|
|
317
|
+
|
|
318
|
+
entity = _make_pie_entity(
|
|
319
|
+
tmp_path,
|
|
320
|
+
"Zone",
|
|
321
|
+
[("Z1", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")],
|
|
322
|
+
)
|
|
323
|
+
result = point_in_entities({"lat": 0.5, "lon": 0.5}, [entity])
|
|
324
|
+
assert result == {"Zone": "Z1"}
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def test_point_in_entities_miss(tmp_path: Any) -> None:
|
|
328
|
+
"""Point outside all polygons returns None."""
|
|
329
|
+
from core_lens.utils.spatial import point_in_entities
|
|
330
|
+
|
|
331
|
+
entity = _make_pie_entity(
|
|
332
|
+
tmp_path,
|
|
333
|
+
"Zone",
|
|
334
|
+
[("Z1", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")],
|
|
335
|
+
)
|
|
336
|
+
result = point_in_entities({"lat": 5.0, "lon": 5.0}, [entity])
|
|
337
|
+
assert result == {"Zone": None}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def test_point_in_entities_multi_entity(tmp_path: Any) -> None:
|
|
341
|
+
"""Each entity resolved independently; mixed hit/miss."""
|
|
342
|
+
from core_lens.utils.spatial import point_in_entities
|
|
343
|
+
|
|
344
|
+
entity_a = _make_pie_entity(
|
|
345
|
+
tmp_path,
|
|
346
|
+
"District",
|
|
347
|
+
[
|
|
348
|
+
("D1", "POLYGON ((0 0, 10 0, 10 10, 0 10, 0 0))"),
|
|
349
|
+
("D2", "POLYGON ((10 0, 20 0, 20 10, 10 10, 10 0))"),
|
|
350
|
+
],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
entity_b = _make_pie_entity(
|
|
354
|
+
tmp_path,
|
|
355
|
+
"Block",
|
|
356
|
+
[("B1", "POLYGON ((0 0, 5 0, 5 5, 0 5, 0 0))")],
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# Point at (3, 3) — inside D1 and B1.
|
|
360
|
+
result = point_in_entities({"lat": 3.0, "lon": 3.0}, [entity_a, entity_b])
|
|
361
|
+
assert result["District"] == "D1"
|
|
362
|
+
assert result["Block"] == "B1"
|
|
363
|
+
|
|
364
|
+
# Point at (15, 5) — inside D2, outside B1.
|
|
365
|
+
result2 = point_in_entities({"lat": 5.0, "lon": 15.0}, [entity_a, entity_b])
|
|
366
|
+
assert result2["District"] == "D2"
|
|
367
|
+
assert result2["Block"] is None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_point_in_entities_lng_alias(tmp_path: Any) -> None:
|
|
371
|
+
"""Accepts 'lng' as alias for longitude."""
|
|
372
|
+
from core_lens.utils.spatial import point_in_entities
|
|
373
|
+
|
|
374
|
+
entity = _make_pie_entity(
|
|
375
|
+
tmp_path,
|
|
376
|
+
"Zone",
|
|
377
|
+
[("Z1", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")],
|
|
378
|
+
)
|
|
379
|
+
result = point_in_entities({"lat": 0.5, "lng": 0.5}, [entity])
|
|
380
|
+
assert result == {"Zone": "Z1"}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_point_in_entities_empty_list(tmp_path: Any) -> None:
|
|
384
|
+
"""Empty entity list returns empty dict."""
|
|
385
|
+
from core_lens.utils.spatial import point_in_entities
|
|
386
|
+
|
|
387
|
+
result = point_in_entities({"lat": 0.5, "lon": 0.5}, [])
|
|
388
|
+
assert result == {}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_point_in_entities_on_boundary(tmp_path: Any) -> None:
|
|
392
|
+
"""Point exactly on polygon boundary — Shapely treats as contained."""
|
|
393
|
+
from core_lens.utils.spatial import point_in_entities
|
|
394
|
+
|
|
395
|
+
entity = _make_pie_entity(
|
|
396
|
+
tmp_path,
|
|
397
|
+
"Zone",
|
|
398
|
+
[("Z1", "POLYGON ((0 0, 1 0, 1 1, 0 1, 0 0))")],
|
|
399
|
+
)
|
|
400
|
+
# Shapely `contains` is strict (boundary not contained) — boundary point
|
|
401
|
+
# may return None. Test documents this behaviour rather than asserting a
|
|
402
|
+
# specific id, so the test remains robust across Shapely versions.
|
|
403
|
+
result = point_in_entities({"lat": 0.0, "lon": 0.5}, [entity])
|
|
404
|
+
assert result["Zone"] in ("Z1", None)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def test_point_in_entities_bbox_skips_exact_check(tmp_path: Any) -> None:
|
|
408
|
+
"""Bbox pre-filter short-circuits: point outside all bboxes skips I/O."""
|
|
409
|
+
from core_lens.utils.spatial import point_in_entities
|
|
410
|
+
|
|
411
|
+
entity = _make_pie_entity(
|
|
412
|
+
tmp_path,
|
|
413
|
+
"Zone",
|
|
414
|
+
[("Z1", "POLYGON ((10 10, 20 10, 20 20, 10 20, 10 10))")],
|
|
415
|
+
)
|
|
416
|
+
# Point at (0, 0) is outside bbox [10,10,20,20], so candidates empty.
|
|
417
|
+
result = point_in_entities({"lat": 0.0, "lon": 0.0}, [entity])
|
|
418
|
+
assert result == {"Zone": None}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# core-lens Usage Guide
|
|
2
|
+
|
|
3
|
+
`core-lens` is a high-performance Python library designed for querying, filtering, and manipulating large-scale geospatial datasets using deferred execution with Polars.
|
|
4
|
+
|
|
5
|
+
This guide covers the core concepts and primary workflows for using the library.
|
|
6
|
+
|
|
7
|
+
## 0. Installation
|
|
8
|
+
|
|
9
|
+
Install `core-lens` via `pip`. The library provides several optional dependency groups depending on your use case:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# Basic installation (Polars + core utilities)
|
|
13
|
+
pip install "core-lens[core]"
|
|
14
|
+
|
|
15
|
+
# Recommended for geospatial querying and exporting (adds DuckDB, GeoPandas, Shapely)
|
|
16
|
+
pip install "core-lens[spatial]"
|
|
17
|
+
|
|
18
|
+
# Full installation with visualization and statistical modeling (excludes GPU)
|
|
19
|
+
pip install "core-lens[full]"
|
|
20
|
+
|
|
21
|
+
# Full installation with Nvidia GPU acceleration via cuDF (Linux only)
|
|
22
|
+
pip install "core-lens[full-gpu]" --extra-index-url=https://pypi.nvidia.com
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Though it is recommended that you use [uv](https://docs.astral.sh/uv/) for dependency management.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
mkdir core-test && cd core-test
|
|
29
|
+
uv init --bare
|
|
30
|
+
uv add core-lens[full]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
## 1. Initialization and Registration
|
|
35
|
+
|
|
36
|
+
The primary entry point for querying data is the **Area of Interest (`AoI`)**. Before an `AoI` can be constructed, the geospatial entities (e.g., Microwatersheds, Tehsils, Villages) must be registered.
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from core_lens import AoI
|
|
40
|
+
from core_lens.entities import MWSEntity, TehsilEntity
|
|
41
|
+
|
|
42
|
+
# 1. Register available entities
|
|
43
|
+
AoI.register(MWSEntity)
|
|
44
|
+
AoI.register(TehsilEntity)
|
|
45
|
+
|
|
46
|
+
# Path to the root directory containing your parquet files
|
|
47
|
+
DATA_ROOT = "path/to/data"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 2. Defining an Area of Interest (AoI)
|
|
51
|
+
|
|
52
|
+
An `AoI` is scoped to a specific spatial boundary. You must initialize it using **exactly one** of three boundary modes: a bounding box, a Shapely geometry, or named entity filters.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import shapely.geometry as sgeom
|
|
56
|
+
|
|
57
|
+
# Mode A: Bounding Box (minx, miny, maxx, maxy)
|
|
58
|
+
aoi = AoI(DATA_ROOT, bbox=(76.0, 31.0, 78.0, 33.0))
|
|
59
|
+
|
|
60
|
+
# Mode B: Shapely Geometry
|
|
61
|
+
polygon = sgeom.box(76.0, 31.0, 78.0, 33.0)
|
|
62
|
+
aoi_geom = AoI(DATA_ROOT, geometry=polygon)
|
|
63
|
+
|
|
64
|
+
# Mode C: Named Entity Attributes / IDs
|
|
65
|
+
# Resolves the geometry of the matching entity (e.g., by district or specific ID)
|
|
66
|
+
aoi_named = AoI(DATA_ROOT, tehsil="Pangi", district="Chamba")
|
|
67
|
+
aoi_id = AoI(DATA_ROOT, mws_id="13_551")
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 3. Querying Entities (Lazy Views)
|
|
71
|
+
|
|
72
|
+
Accessing a registered entity on the `AoI` object (e.g., `aoi.mws`) returns a lazy **`View`**. A View builds up a query plan without executing any I/O until you explicitly materialize it.
|
|
73
|
+
|
|
74
|
+
### Filtering Views
|
|
75
|
+
|
|
76
|
+
Views can be chained with different types of filters:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Base view: All MWS falling within the AoI
|
|
80
|
+
mws_view = aoi.mws
|
|
81
|
+
|
|
82
|
+
# 1. Attribute Filtering
|
|
83
|
+
filtered_view = mws_view.where(state="Himachal Pradesh")
|
|
84
|
+
|
|
85
|
+
# 2. Spatial Filtering (Narrowing down within the AoI)
|
|
86
|
+
spatial_view = mws_view.spatial_filter(bbox=(76.5, 31.5, 77.5, 32.5))
|
|
87
|
+
|
|
88
|
+
# 3. Temporal Filtering (Filters sub-annual/annual data)
|
|
89
|
+
from core_lens.base.view import Season
|
|
90
|
+
|
|
91
|
+
temporal_view = mws_view.between(
|
|
92
|
+
year=(2020, 2022), # Inclusive year range
|
|
93
|
+
season=Season.KHARIF # Filter to specific season
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Cross-Entity Spatial Joins
|
|
98
|
+
|
|
99
|
+
You can define a spatial join to enrich one entity with aggregated data from another intersecting entity. This is evaluated lazily. This is still experimental.
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
# Join MWS with Tehsil entity to calculate the sum of tehsil areas per MWS
|
|
103
|
+
joined_view = mws_view.spatial_join(
|
|
104
|
+
other=TehsilEntity(data_root=DATA_ROOT),
|
|
105
|
+
agg={"Shape_Area": "sum"}
|
|
106
|
+
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## 4. Materialization
|
|
110
|
+
|
|
111
|
+
Once your query is fully constructed, you materialize the `View` into a concrete `Result`. You must specify the temporal grain (`static`, `annual`, or `sub_annual`) you wish to load.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
# Materialize the static dataset (one row per entity)
|
|
115
|
+
result_static = filtered_view.static.materialise()
|
|
116
|
+
|
|
117
|
+
# Materialize annual time-series data
|
|
118
|
+
result_annual = filtered_view.annual.materialise()
|
|
119
|
+
|
|
120
|
+
# Materialize highly granular sub-annual time-series data
|
|
121
|
+
result_sub_annual = temporal_view.sub_annual.materialise()
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## 5. Working with Results
|
|
125
|
+
|
|
126
|
+
A `Result` object provides access to the materialized data. `core-lens` uses zero-copy Polars DataFrames by default for speed, but integrates with GeoPandas when spatial manipulation is required.
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
# 1. Zero-copy access (Instantaneous)
|
|
130
|
+
pl_df = result_static.df() # Polars DataFrame
|
|
131
|
+
pl_lazy = result_static.lazy() # Polars LazyFrame
|
|
132
|
+
|
|
133
|
+
# 2. Decode WKB geometries to Shapely (CPU intensive)
|
|
134
|
+
gdf = result_static.gdf() # GeoPandas GeoDataFrame
|
|
135
|
+
|
|
136
|
+
# 3. Data Transformations (Returns a new Result)
|
|
137
|
+
# Derive new columns using Polars expressions
|
|
138
|
+
import polars as pl
|
|
139
|
+
result_derived = result_annual.derive("yield_per_ha", pl.col("yield") / pl.col("area_in_ha"))
|
|
140
|
+
|
|
141
|
+
# 4. Fast aggregations (Returns a new Result)
|
|
142
|
+
# Aggregate by a key column using Polars expressions
|
|
143
|
+
result_agg = result_derived.aggregate(
|
|
144
|
+
pl.col("yield_per_ha").mean().alias("avg_yield_per_ha"),
|
|
145
|
+
by="year"
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## 6. Exporting Data
|
|
150
|
+
|
|
151
|
+
For interoperability, results can be exported to various standard formats. Avoid exporting large, country-wide datasets to GeoJSON due to significant file size bloat.
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from core_lens.export import formats as export
|
|
155
|
+
|
|
156
|
+
# Export to GeoParquet (Fast, efficient spatial format)
|
|
157
|
+
export.geoparquet(result_static, "output/mws.geoparquet")
|
|
158
|
+
|
|
159
|
+
# Export to GeoJSON (Slower, text-based)
|
|
160
|
+
export.geojson(result_static, "output/mws.geojson")
|
|
161
|
+
|
|
162
|
+
# Export without geometries (Uses Polars directly)
|
|
163
|
+
export.parquet(result_static, "output/mws.parquet")
|
|
164
|
+
export.csv(result_static, "output/mws.csv")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Advanced Configuration
|
|
168
|
+
|
|
169
|
+
### Custom Seasons
|
|
170
|
+
By default, `core-lens` uses Indian agricultural seasons. You can inject a custom `SeasonConfig` at AoI initialization if you need to map time series data to different date ranges:
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
from core_lens.aoi import SeasonConfig
|
|
174
|
+
|
|
175
|
+
custom_seasons = SeasonConfig(
|
|
176
|
+
kharif=("06-01", "10-15"),
|
|
177
|
+
rabi=("10-16", "02-28"),
|
|
178
|
+
zaid=("03-01", "05-31")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
aoi = AoI(DATA_ROOT, bbox=(...), seasons=custom_seasons)
|
|
182
|
+
```
|