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.
Files changed (93) hide show
  1. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/aoi.py +2 -1
  4. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/spatial.py +106 -0
  5. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_spatial.py +177 -0
  6. core_lens-0.1.dev178/usage.md +182 -0
  7. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/uv.lock +190 -190
  8. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  9. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  10. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  11. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/pull_request_template.md +0 -0
  12. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/ci.yml +0 -0
  13. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/gh-pages.yml +0 -0
  14. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/pre-release.yml +0 -0
  15. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.github/workflows/release.yml +0 -0
  16. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.gitignore +0 -0
  17. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.gitmessage +0 -0
  18. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.pre-commit-config.yaml +0 -0
  19. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/.python-version +0 -0
  20. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/CONTRIBUTING.md +0 -0
  21. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/LICENSE +0 -0
  22. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/README.md +0 -0
  23. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/SKILLS.md +0 -0
  24. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/README.md +0 -0
  25. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_aoi.py +0 -0
  26. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_entity.py +0 -0
  27. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_export.py +0 -0
  28. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_polars_utils.py +0 -0
  29. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_result.py +0 -0
  30. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_schema.py +0 -0
  31. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_season.py +0 -0
  32. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_spatial.py +0 -0
  33. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/bench_view.py +0 -0
  34. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/benchmarks/run_all.sh +0 -0
  35. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/Makefile +0 -0
  36. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/make.bat +0 -0
  37. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/concepts.md +0 -0
  38. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/conf.py +0 -0
  39. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/export.md +0 -0
  40. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/index.rst +0 -0
  41. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/intro.md +0 -0
  42. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/logging.md +0 -0
  43. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/plots.md +0 -0
  44. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/plugins.md +0 -0
  45. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/queries.md +0 -0
  46. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/quickstart.md +0 -0
  47. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/docs/source/stats.md +0 -0
  48. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/examples/demo_mws.py +0 -0
  49. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/examples/demo_tehsil.py +0 -0
  50. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/hooks/mypy.sh +0 -0
  51. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/hooks/no-parquet-outside-fixtures.sh +0 -0
  52. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/hooks/pytest.sh +0 -0
  53. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/pyproject.toml +0 -0
  54. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/__init__.py +0 -0
  55. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/__main__.py +0 -0
  56. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/__init__.py +0 -0
  57. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/entity.py +0 -0
  58. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/namespaces/__init__.py +0 -0
  59. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/namespaces/plot.py +0 -0
  60. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/namespaces/stats.py +0 -0
  61. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/result.py +0 -0
  62. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/base/view.py +0 -0
  63. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/__init__.py +0 -0
  64. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/mws.py +0 -0
  65. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/tehsil.py +0 -0
  66. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/entities/waterbody.py +0 -0
  67. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/export/__init__.py +0 -0
  68. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/export/formats.py +0 -0
  69. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/py.typed +0 -0
  70. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/schema/__init__.py +0 -0
  71. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/schema/detection.py +0 -0
  72. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/schema/profile.py +0 -0
  73. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/__init__.py +0 -0
  74. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/paths.py +0 -0
  75. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/polars_utils.py +0 -0
  76. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/src/core_lens/utils/season.py +0 -0
  77. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/fixtures/generate_fixtures.py +0 -0
  78. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/conftest.py +0 -0
  79. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_aoi.py +0 -0
  80. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_entities.py +0 -0
  81. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_entity.py +0 -0
  82. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_export.py +0 -0
  83. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_main.py +0 -0
  84. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_plot.py +0 -0
  85. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_polars_utils.py +0 -0
  86. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_profile.py +0 -0
  87. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_result.py +0 -0
  88. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_schema_detection.py +0 -0
  89. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_schema_profile.py +0 -0
  90. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_season.py +0 -0
  91. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_season_config.py +0 -0
  92. {core_lens-0.1.dev175 → core_lens-0.1.dev178}/tests/unit/test_stats.py +0 -0
  93. {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.dev175
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.dev175'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev175')
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, MWSEntity, TehsilEntity
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
+ ```