core-lens 0.1.dev135__tar.gz → 0.1.dev138__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 (91) hide show
  1. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/export.md +8 -2
  3. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/intro.md +3 -1
  4. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/queries.md +3 -2
  5. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/quickstart.md +4 -4
  6. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/_version.py +2 -2
  7. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/aoi.py +221 -125
  8. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/schema/detection.py +8 -0
  9. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/utils/spatial.py +136 -23
  10. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  11. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  12. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  13. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/pull_request_template.md +0 -0
  14. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/workflows/ci.yml +0 -0
  15. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/workflows/gh-pages.yml +0 -0
  16. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/workflows/pre-release.yml +0 -0
  17. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.github/workflows/release.yml +0 -0
  18. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.gitignore +0 -0
  19. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.gitmessage +0 -0
  20. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.pre-commit-config.yaml +0 -0
  21. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/.python-version +0 -0
  22. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/CONTRIBUTING.md +0 -0
  23. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/LICENSE +0 -0
  24. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/README.md +0 -0
  25. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/SKILLS.md +0 -0
  26. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/README.md +0 -0
  27. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_aoi.py +0 -0
  28. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_export.py +0 -0
  29. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_polars_utils.py +0 -0
  30. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_result.py +0 -0
  31. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_schema.py +0 -0
  32. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_season.py +0 -0
  33. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_spatial.py +0 -0
  34. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/bench_view.py +0 -0
  35. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/benchmarks/run_all.sh +0 -0
  36. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/Makefile +0 -0
  37. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/make.bat +0 -0
  38. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/concepts.md +0 -0
  39. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/conf.py +0 -0
  40. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/index.rst +0 -0
  41. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/logging.md +0 -0
  42. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/plots.md +0 -0
  43. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/plugins.md +0 -0
  44. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/docs/source/stats.md +0 -0
  45. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/examples/demo_mws.py +0 -0
  46. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/examples/demo_tehsil.py +0 -0
  47. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/hooks/mypy.sh +0 -0
  48. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/hooks/no-parquet-outside-fixtures.sh +0 -0
  49. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/hooks/pytest.sh +0 -0
  50. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/pyproject.toml +0 -0
  51. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/__init__.py +0 -0
  52. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/__main__.py +0 -0
  53. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/__init__.py +0 -0
  54. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/entity.py +0 -0
  55. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/namespaces/__init__.py +0 -0
  56. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/namespaces/plot.py +0 -0
  57. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/namespaces/stats.py +0 -0
  58. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/result.py +0 -0
  59. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/base/view.py +0 -0
  60. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/entities/__init__.py +0 -0
  61. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/entities/mws.py +0 -0
  62. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/entities/tehsil.py +0 -0
  63. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/entities/waterbody.py +0 -0
  64. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/export/__init__.py +0 -0
  65. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/export/formats.py +0 -0
  66. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/py.typed +0 -0
  67. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/schema/__init__.py +0 -0
  68. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/schema/profile.py +0 -0
  69. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/utils/__init__.py +0 -0
  70. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/utils/paths.py +0 -0
  71. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/utils/polars_utils.py +0 -0
  72. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/src/core_lens/utils/season.py +0 -0
  73. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/fixtures/generate_fixtures.py +0 -0
  74. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/conftest.py +0 -0
  75. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_aoi.py +0 -0
  76. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_entities.py +0 -0
  77. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_entity.py +0 -0
  78. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_export.py +0 -0
  79. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_main.py +0 -0
  80. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_plot.py +0 -0
  81. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_polars_utils.py +0 -0
  82. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_profile.py +0 -0
  83. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_result.py +0 -0
  84. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_schema_detection.py +0 -0
  85. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_schema_profile.py +0 -0
  86. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_season.py +0 -0
  87. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_season_config.py +0 -0
  88. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_spatial.py +0 -0
  89. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_stats.py +0 -0
  90. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/tests/unit/test_view.py +0 -0
  91. {core_lens-0.1.dev135 → core_lens-0.1.dev138}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-lens
3
- Version: 0.1.dev135
3
+ Version: 0.1.dev138
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
@@ -41,7 +41,13 @@ geoparquet(
41
41
 
42
42
  # Export to GeoJSON
43
43
  geojson(spatial_res, "output.json")
44
+
45
+ # Export to newline-delimited GeoJSON (GeoJSONSeq)
46
+ geojson(spatial_res, "output.ndjson", driver="GeoJSONSeq")
44
47
  ```
45
48
 
46
- > [!NOTE]
47
- > Exporting to geospatial formats automatically uses DuckDB to cast WKB geometries to proper spatial types via the `spatial` extension. CoreLens handles the installation and loading of the extension seamlessly under the hood.
49
+ ```{note}
50
+ Exporting to **GeoParquet** uses DuckDB to cast WKB geometries to proper spatial types via the `spatial` extension. CoreLens handles the installation and loading of the extension seamlessly under the hood.
51
+
52
+ Exporting to **GeoJSON** is fully natively streamed directly from Polars and Shapely without invoking GDAL or DuckDB, making it extremely fast for large datasets. Driver-specific kwargs for GeoJSON are ignored.
53
+ ```
@@ -13,4 +13,6 @@ CoreLens provides a unified interface over microwatersheds, administrative bound
13
13
  - **Temporal & Seasonal Awareness**: Native support for agronomic seasons (Kharif, Rabi, Zaid) and time-range filtering.
14
14
  - **Spatial Statistics & Analysis**: Built-in methods for anomaly detection, spatial similarity, temporal correlation, and hypothesis testing.
15
15
  - **Interactive Visualisation**: Generate interactive maps using Lonboard with zero-copy GeoArrow rendering directly from Polars, and timeseries/scatter plots using Bokeh.
16
- - **High-Performance Exports**: Export results directly to Parquet, JSON, CSV, or spatially-enabled GeoParquet and GeoJSON using integrated DuckDB spatial extensions.
16
+ - **Cloud & Local Data**: Native support for reading Parquet data from local filesystems or directly from cloud storage (e.g., `s3://`).
17
+ - **High-Performance Exports**: Export results directly to Parquet, JSON, CSV, or spatially-enabled GeoParquet and GeoJSON (with a fast streaming writer).
18
+ - **Advanced Caching**: Transparently caches boundaries and pre-computes spatial indices (via on-disk sidecars) to achieve sub-second query initialisation times.
@@ -58,5 +58,6 @@ joined_view = aoi.mws.spatial_join(
58
58
  # res = joined_view.annual
59
59
  ```
60
60
 
61
- > [!NOTE]
62
- > Cross-entity spatial join execution (materialisation) is currently under development and will be added in a subsequent release. Calling materialisation methods (like `.annual`, `.static`, or `.fortnightly`) on a joined view will raise a `NotImplementedError` in the current version.
61
+ ```{note}
62
+ Cross-entity spatial join execution (materialisation) is currently under development and will be added in a subsequent release. Calling materialisation methods (like `.annual`, `.static`, or `.fortnightly`) on a joined view will raise a `NotImplementedError` in the current version.
63
+ ```
@@ -25,18 +25,18 @@ AoI.register(MWSEntity)
25
25
  AoI.register(TehsilEntity)
26
26
  ```
27
27
 
28
- Create an `AoI` (Area of Interest) by specifying the `data_root` and your boundary (either by name, bounding box, or Shapely geometry):
28
+ Create an `AoI` (Area of Interest) by specifying the `data_root` (which can be a local path or a cloud URI like `s3://bucket/data`) and your boundary (either by name, bounding box, or Shapely geometry):
29
29
 
30
30
  ```python
31
- # Create an AoI scoped to a specific Tehsil
31
+ # Create an AoI scoped to a specific Tehsil using a local path
32
32
  aoi = AoI(
33
33
  data_root="/path/to/data",
34
34
  tehsil="Pangi"
35
35
  )
36
36
 
37
- # Or scope it directly to a specific list of entity IDs
37
+ # Or scope it directly to a specific list of entity IDs using an S3 bucket
38
38
  aoi_specific = AoI(
39
- data_root="/path/to/data",
39
+ data_root="s3://my-cloud-bucket/core-lens-data",
40
40
  mws_id=["13_001", "13_002"]
41
41
  )
42
42
 
@@ -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.dev135'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev135')
21
+ __version__ = version = '0.1.dev138'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev138')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import functools
5
6
  import os
6
7
  import pathlib
7
8
  from dataclasses import dataclass
@@ -12,13 +13,15 @@ if TYPE_CHECKING:
12
13
  from core_lens.base.result import Result
13
14
 
14
15
  import polars as pl
16
+ import shapely
17
+ import shapely.geometry as sgeom
18
+ import shapely.ops as sops
15
19
  from loguru import logger
16
20
 
17
21
  from core_lens.base.entity import BaseEntity, EntityValidationError
18
22
  from core_lens.utils.paths import is_cloud_uri
19
23
 
20
24
  if TYPE_CHECKING:
21
- import shapely
22
25
  from core_lens.base.view import View
23
26
 
24
27
 
@@ -125,6 +128,121 @@ def _default_season_config() -> SeasonConfig:
125
128
  _REGISTRY: dict[str, type[BaseEntity]] = {}
126
129
 
127
130
 
131
+ # ---------------------------------------------------------------------------
132
+ # Process-level boundary cache — shared across all AoI instances.
133
+ #
134
+ # Keyed on (data_root, storage_options_key, entity_kwargs_key).
135
+ # Returns a (geometry_wkb, entity_name, key_rows_tuple) triple so the
136
+ # expensive scan + WKB decode + unary_union is only paid once per process
137
+ # per unique boundary specification.
138
+ #
139
+ # Cache diagnostics: _cached_resolve_boundary.cache_info()
140
+ # Cache reset (tests): _cached_resolve_boundary.cache_clear()
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ @functools.cache
145
+ def _cached_resolve_boundary(
146
+ data_root: str,
147
+ storage_options_key: tuple[tuple[str, Any], ...],
148
+ entity_kwargs_key: tuple[tuple[str, Any], ...],
149
+ ) -> tuple[bytes, str, tuple[tuple[Any, ...], ...]]:
150
+ """Cached boundary resolution: return ``(geometry_wkb, entity_name, key_rows)``.
151
+
152
+ All arguments must be hashable. ``entity_kwargs`` must be converted to a
153
+ sorted tuple of ``(key, value)`` pairs before calling this function.
154
+
155
+ The returned triple contains:
156
+
157
+ * **geometry_wkb** — WKB bytes of the unioned boundary geometry.
158
+ * **entity_name** — name of the entity that defined the boundary
159
+ (e.g. ``"mws"`` or ``"tehsil"``).
160
+ * **key_rows** — tuple of key-value tuples for the matched rows
161
+ (e.g. ``(("mws_id", "13_551"),)`` for a single-row result).
162
+
163
+ Args:
164
+ data_root (str): Resolved data root path or cloud URI.
165
+ storage_options_key (tuple): Sorted ``storage_options`` items (hashable).
166
+ entity_kwargs_key (tuple): Sorted ``entity_kwargs`` items (hashable).
167
+
168
+ Returns:
169
+ tuple: ``(geometry_wkb, entity_name, key_rows)``.
170
+ """
171
+ from core_lens.base.entity import _entity_name as _ename
172
+
173
+ entity_kwargs: dict[str, Any] = dict(entity_kwargs_key)
174
+ storage_options: dict[str, Any] = dict(storage_options_key)
175
+
176
+ # Instantiate a throw-away AoI-like context to run the resolution logic.
177
+ # We cannot use a live AoI instance here (would be circular), so we
178
+ # inline the logic from _resolve_named_boundary.
179
+ candidate: BaseEntity | None = None
180
+
181
+ # First pass: find entity whose schema columns match the kwargs.
182
+ for ename in _REGISTRY:
183
+ entity_cls = _REGISTRY[ename]
184
+ entity = entity_cls(
185
+ data_root=data_root,
186
+ storage_options=storage_options or None,
187
+ )
188
+ schema = entity.schema_profile # uses _cached_detect — I/O only once
189
+ if any(
190
+ k in schema.key_cols or k in schema.extra_static_cols for k in entity_kwargs
191
+ ):
192
+ candidate = entity
193
+ break
194
+
195
+ # Fallback: entity name matches a kwarg key.
196
+ if candidate is None:
197
+ for ename in _REGISTRY:
198
+ if ename in entity_kwargs:
199
+ entity_cls = _REGISTRY[ename]
200
+ candidate = entity_cls(
201
+ data_root=data_root,
202
+ storage_options=storage_options or None,
203
+ )
204
+ break
205
+
206
+ if candidate is None:
207
+ raise EntityValidationError(
208
+ f"No registered entity can satisfy the filters {entity_kwargs}. "
209
+ f"Registered entities: {sorted(_REGISTRY)}."
210
+ )
211
+
212
+ schema = candidate.schema_profile
213
+ geom_col = schema.geometry_col
214
+ static_path = candidate._resolve(candidate.static_path)
215
+
216
+ lf = pl.scan_parquet(static_path, storage_options=storage_options or None)
217
+ filter_expr = pl.lit(True)
218
+ for col, val in entity_kwargs.items():
219
+ if col in schema.key_cols or col in schema.extra_static_cols:
220
+ if isinstance(val, list):
221
+ lf = lf.filter(pl.col(col).is_in(val))
222
+ else:
223
+ filter_expr = filter_expr & (pl.col(col) == val)
224
+
225
+ df = lf.filter(filter_expr).select(candidate.key_cols + [geom_col]).collect()
226
+
227
+ if df.is_empty():
228
+ raise ValueError(
229
+ f"No rows matched the filters {entity_kwargs} in {candidate.static_path!r}."
230
+ )
231
+
232
+ geoms = shapely.from_wkb(df[geom_col].to_numpy())
233
+ unified = sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
234
+ geom_wkb: bytes = shapely.to_wkb(unified)
235
+
236
+ entity_name = _ename(type(candidate))
237
+ # Serialise key DataFrame as a tuple-of-tuples for the cache return value.
238
+ key_rows = tuple(
239
+ tuple(row[c] for c in candidate.key_cols)
240
+ for row in df.select(candidate.key_cols).to_dicts()
241
+ )
242
+
243
+ return geom_wkb, entity_name, key_rows
244
+
245
+
128
246
  class AoI:
129
247
  """Area of Interest — the primary entry point for querying geospatial data.
130
248
 
@@ -365,7 +483,9 @@ class AoI:
365
483
  data_root=self.data_root,
366
484
  storage_options=self._storage_options or None,
367
485
  )
368
- _validate_entity(entity, name)
486
+ # Only validate path existence here — schema detection (parquet
487
+ # footer reads) is deferred to first data access via schema_profile.
488
+ _validate_entity_paths(entity, name)
369
489
  self._entity_instances[name] = entity
370
490
  return self._entity_instances[name]
371
491
 
@@ -427,9 +547,11 @@ class AoI:
427
547
  ) -> "shapely.Geometry":
428
548
  """Resolve a set of named attribute filters to a Shapely geometry.
429
549
 
430
- The entity whose key column matches one of the kwargs is queried.
431
- Multiple kwargs act as AND-filters (e.g. tehsil + district narrows to
432
- the unique matching row).
550
+ Delegates to the process-level :func:`_cached_resolve_boundary` cache
551
+ so that the expensive Parquet scan + WKB decode + ``unary_union`` is
552
+ only paid once per unique ``(data_root, entity_kwargs)`` combination
553
+ within a Python process. Subsequent ``AoI()`` calls with the same
554
+ boundary specification reconstruct the geometry from cached WKB bytes.
433
555
 
434
556
  Args:
435
557
  entity_kwargs (dict[str, str | list[str]]): Column–value pairs used to identify the boundary.
@@ -441,85 +563,47 @@ class AoI:
441
563
  :class:`~core_lens.base.EntityValidationError`: If no registered entity can satisfy the filters.
442
564
  ValueError: If the filters match zero rows.
443
565
  """
444
- import shapely.ops as sops
445
-
446
566
  logger.debug("Resolving named boundary using kwargs: {}", entity_kwargs)
447
567
 
448
- # Find the registered entity whose key_col or known attribute column
449
- # matches one of the filter keys. Entities are lazily instantiated
450
- # via _get_entity() only those inspected during the search are built.
451
- candidate: BaseEntity | None = None
452
- for name in _REGISTRY:
453
- entity = self._get_entity(name)
454
- schema = entity.schema_profile
455
- if any(
456
- k in schema.key_cols or k in schema.extra_static_cols
457
- for k in entity_kwargs
458
- ):
459
- candidate = entity
460
- break
461
-
462
- # Fall back: look for an entity whose name matches a kwarg key
463
- # (e.g. tehsil="Pangi" → TehsilEntity if registered as "tehsil").
464
- if candidate is None:
465
- logger.debug(
466
- "No direct column match found for kwargs, attempting entity name fallback"
467
- )
468
- for name in _REGISTRY:
469
- if name in entity_kwargs:
470
- candidate = self._get_entity(name)
471
- break
472
-
473
- if candidate is None:
474
- logger.error(
475
- "Boundary resolution failed: no registered entity matched filters {}",
476
- entity_kwargs,
477
- )
478
- raise EntityValidationError(
479
- f"No registered entity can satisfy the filters {entity_kwargs}. "
480
- f"Registered entities: {sorted(_REGISTRY)}."
481
- )
482
-
483
- schema = candidate.schema_profile
484
- geom_col = schema.geometry_col
485
-
486
- # Build a lazy frame to push filters down into the Parquet reader.
487
- # This avoids loading the entire geometry column into memory.
488
- lf = pl.scan_parquet(candidate._resolve(candidate.static_path))
489
-
490
- filter_expr = pl.lit(True)
491
- for col, val in entity_kwargs.items():
492
- if (
493
- col in candidate.schema_profile.key_cols
494
- or col in candidate.schema_profile.extra_static_cols
495
- ):
496
- if isinstance(val, list):
497
- lf = lf.filter(pl.col(col) == val)
498
- else:
499
- filter_expr = filter_expr & (pl.col(col) == val)
500
-
501
- df = lf.filter(filter_expr).select(candidate.key_cols + [geom_col]).collect()
502
-
503
- # Cache the boundary-defining entity's name and its exact key rows so
504
- # that __getattr__ can short-circuit the spatial_filter for this one
505
- # entity — the keys are already known, no need to rebuild the bbox
506
- # index or re-run a geometric predicate.
507
- self._boundary_entity_name = _entity_name(type(candidate))
508
- self._boundary_keys = df.select(candidate.key_cols)
568
+ from core_lens.base.entity import _so_key
569
+
570
+ # Build hashable cache key from entity_kwargs.
571
+ # list values are converted to tuples so they are hashable.
572
+ kwargs_hashable: dict[str, Any] = {
573
+ k: tuple(v) if isinstance(v, list) else v for k, v in entity_kwargs.items()
574
+ }
575
+ entity_kwargs_key = tuple(sorted(kwargs_hashable.items()))
576
+ data_root_str = str(self.data_root)
577
+ so_key = _so_key(self._storage_options)
578
+
579
+ # Delegate to the process-level cache. On a cache miss this runs the
580
+ # full scan + WKB decode + unary_union; on a hit it returns instantly.
581
+ geom_wkb, boundary_entity_name, key_rows = _cached_resolve_boundary(
582
+ data_root_str, so_key, entity_kwargs_key
583
+ )
509
584
 
510
- if df.is_empty():
511
- logger.error(
512
- "Boundary resolution failed: no rows matched filters {}", entity_kwargs
585
+ # Reconstruct the boundary geometry from cached WKB bytes (fast).
586
+ geometry: shapely.Geometry = shapely.from_wkb(geom_wkb)
587
+
588
+ # Reconstruct _boundary_keys DataFrame from cached key_rows.
589
+ # We need the key column names — get them from the boundary entity.
590
+ boundary_entity_cls = _REGISTRY.get(boundary_entity_name)
591
+ if boundary_entity_cls is not None:
592
+ key_cols = boundary_entity_cls().key_cols
593
+ self._boundary_entity_name = boundary_entity_name
594
+ self._boundary_keys = pl.DataFrame(
595
+ {col: [row[i] for row in key_rows] for i, col in enumerate(key_cols)}
513
596
  )
514
- raise ValueError(
515
- f"No rows matched the filters {entity_kwargs} "
516
- f"in {candidate.static_path!r}."
597
+ else:
598
+ # Registry changed since cache was populated — defensive fallback.
599
+ logger.warning(
600
+ "Boundary cache returned entity name '{}' not found in current registry.",
601
+ boundary_entity_name,
517
602
  )
603
+ self._boundary_entity_name = None
604
+ self._boundary_keys = None
518
605
 
519
- import shapely
520
-
521
- geoms = shapely.from_wkb(df[geom_col].to_numpy())
522
- return sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
606
+ return geometry
523
607
 
524
608
  @classmethod
525
609
  def register(cls, entity_cls: type[BaseEntity]) -> None:
@@ -588,27 +672,29 @@ def _entity_name(entity_cls: type[BaseEntity]) -> str:
588
672
  def _bbox_to_polygon(
589
673
  bbox: tuple[float, float, float, float],
590
674
  ) -> "shapely.Geometry":
591
- import shapely.geometry as sgeom
592
-
593
675
  minx, miny, maxx, maxy = bbox
594
676
  return sgeom.box(minx, miny, maxx, maxy)
595
677
 
596
678
 
597
- def _validate_entity(entity: BaseEntity, name: str) -> None:
598
- """Validate an entity's paths and schema.
679
+ def _validate_entity_paths(entity: BaseEntity, name: str) -> None:
680
+ """Validate an entity's path existence only — no Parquet I/O.
599
681
 
600
- File-existence checks are performed eagerly for local paths. Schema
601
- validation (key columns, geometry column) is delegated to
602
- :attr:`BaseEntity.schema_profile`, which routes through the process-level
603
- :func:`_cached_detect` cache so the second ``AoI()`` pointed at the
604
- same ``data_root`` pays zero Parquet footer reads for validation.
682
+ Checks that ``static_path``, ``annual_path``, and ``fortnightly_path``
683
+ resolve to existing local files. Schema validation (key columns,
684
+ geometry column, geometry type) is intentionally deferred to first data
685
+ access via :attr:`BaseEntity.schema_profile`, which routes through the
686
+ process-level :func:`~core_lens.base.entity._cached_detect` cache.
687
+
688
+ This is the fast variant called from :meth:`AoI._get_entity`. Use
689
+ :func:`_validate_entity` for the full eager check (e.g. in
690
+ :meth:`AoI.validate` or for absolute-path entities at register time).
605
691
 
606
692
  Args:
607
693
  entity (BaseEntity): The entity instance to validate.
608
694
  name (str): Human-readable entity name for error messages.
609
695
 
610
696
  Raises:
611
- EntityValidationError: If any validation check fails.
697
+ EntityValidationError: If any path does not exist.
612
698
  """
613
699
  # --- Static path existence check ----------------------------------------
614
700
  try:
@@ -623,9 +709,6 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
623
709
  f"Entity {name!r}: static_path {entity.static_path!r} does not exist."
624
710
  )
625
711
 
626
- # For local paths perform an eager existence check; for cloud paths we rely
627
- # on the schema-read below to surface a missing-file error (avoids an extra
628
- # HeadObject call per entity at startup).
629
712
  if not is_cloud_uri(static) and not os.path.exists(static):
630
713
  logger.error(
631
714
  "Validation failed for entity {}: static path '{}' does not exist.",
@@ -636,26 +719,7 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
636
719
  f"Entity {name!r}: static_path {static!r} does not exist."
637
720
  )
638
721
 
639
- # --- Schema validation (key_cols, geometry_col, geometry_type) -----------
640
- # Delegate to schema_profile which routes through _cached_detect.
641
- # detect() internally calls _require_cols for key_cols and geometry_col,
642
- # and infers geometry_type — so this covers the same checks that the old
643
- # _validate_entity did with a raw pl.scan_parquet().collect_schema(), but
644
- # without a redundant footer read.
645
- try:
646
- _ = entity.schema_profile
647
- except Exception as exc:
648
- logger.error(
649
- "Validation failed for entity {}: could not read schema from '{}': {}",
650
- name,
651
- static,
652
- exc,
653
- )
654
- raise EntityValidationError(
655
- f"Entity {name!r}: could not read schema from {static!r}: {exc}"
656
- ) from exc
657
-
658
- # --- Temporal path existence checks -------------------------------------
722
+ # --- Temporal path existence checks (no schema read) --------------------
659
723
  for attr, label in [("annual_path", "annual"), ("fortnightly_path", "fortnightly")]:
660
724
  path = getattr(entity, attr)
661
725
  if path is not None:
@@ -663,10 +727,8 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
663
727
  abs_path = entity._resolve(path)
664
728
  except FileNotFoundError:
665
729
  abs_path = None
666
- if (
667
- abs_path is not None
668
- and not is_cloud_uri(abs_path)
669
- and not os.path.exists(abs_path)
730
+ if abs_path is None or (
731
+ not is_cloud_uri(abs_path) and not os.path.exists(abs_path)
670
732
  ):
671
733
  logger.error(
672
734
  "Validation failed for entity {}: {} path '{}' does not exist.",
@@ -677,13 +739,47 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
677
739
  raise EntityValidationError(
678
740
  f"Entity {name!r}: {label}_path {path!r} does not exist."
679
741
  )
680
- elif abs_path is None:
681
- logger.error(
682
- "Validation failed for entity {}: {} path '{}' does not exist.",
683
- name,
684
- label,
685
- path,
686
- )
687
- raise EntityValidationError(
688
- f"Entity {name!r}: {label}_path {path!r} does not exist."
689
- )
742
+
743
+
744
+ def _validate_entity(entity: BaseEntity, name: str) -> None:
745
+ """Validate an entity's paths and schema (full eager check).
746
+
747
+ Runs path existence checks via :func:`_validate_entity_paths`, then
748
+ triggers schema detection (Parquet footer reads) by accessing
749
+ :attr:`BaseEntity.schema_profile`. The result is cached by
750
+ :func:`~core_lens.base.entity._cached_detect`, so the second call for the
751
+ same ``data_root`` pays no I/O.
752
+
753
+ Called by :meth:`AoI.validate` and by :meth:`AoI.register` for
754
+ entities with absolute paths. Normal lazy instantiation via
755
+ :meth:`AoI._get_entity` uses the cheaper :func:`_validate_entity_paths`.
756
+
757
+ Args:
758
+ entity (BaseEntity): The entity instance to validate.
759
+ name (str): Human-readable entity name for error messages.
760
+
761
+ Raises:
762
+ EntityValidationError: If any validation check fails.
763
+ """
764
+ # Fast path-only check first.
765
+ _validate_entity_paths(entity, name)
766
+
767
+ # --- Schema validation (key_cols, geometry_col, geometry_type) -----------
768
+ # Accessing schema_profile triggers _cached_detect, which reads Parquet
769
+ # footer metadata. Subsequent calls for the same data_root are free.
770
+ try:
771
+ static = entity._resolve(entity.static_path)
772
+ _ = entity.schema_profile
773
+ except EntityValidationError:
774
+ raise
775
+ except Exception as exc:
776
+ static = entity.static_path # best-effort for the error message
777
+ logger.error(
778
+ "Validation failed for entity {}: could not read schema from '{}': {}",
779
+ name,
780
+ static,
781
+ exc,
782
+ )
783
+ raise EntityValidationError(
784
+ f"Entity {name!r}: could not read schema from {static!r}: {exc}"
785
+ ) from exc
@@ -178,6 +178,14 @@ def _infer_bbox_cols(schema: pl.Schema) -> tuple[str, str, str, str] | None:
178
178
  for pattern in _BBOX_PATTERNS:
179
179
  if all(col in schema for col in pattern):
180
180
  return pattern
181
+
182
+ for col_name, dtype in schema.items():
183
+ if isinstance(dtype, pl.Struct):
184
+ struct_fields = [f.name for f in dtype.fields]
185
+ for pattern in _BBOX_PATTERNS:
186
+ if all(f in struct_fields for f in pattern):
187
+ return tuple(f"{col_name}.{f}" for f in pattern) # type: ignore
188
+
181
189
  return None
182
190
 
183
191
 
@@ -5,7 +5,9 @@ from __future__ import annotations
5
5
  import pathlib
6
6
  from typing import TYPE_CHECKING, Any
7
7
 
8
+ import numpy as np
8
9
  import polars as pl
10
+ import pyarrow.dataset as ds
9
11
  import shapely
10
12
  from loguru import logger
11
13
 
@@ -14,6 +16,98 @@ from core_lens.utils.paths import is_cloud_uri, path_exists, resolve_fs_and_path
14
16
  if TYPE_CHECKING:
15
17
  pass
16
18
 
19
+ # ---------------------------------------------------------------------------
20
+ # Bbox index sidecar helpers
21
+ #
22
+ # A sidecar Parquet file caches the pre-computed bounding-box index so that
23
+ # the expensive WKB-decode loop in build_bbox_index is only paid once per
24
+ # dataset (not once per process). Subsequent runs load the index directly
25
+ # from the sidecar — no geometry decoding required.
26
+ #
27
+ # Sidecar location priority:
28
+ # 1. Alongside the static parquet directory/file (requires write access).
29
+ # 2. ~/.cache/core_lens/ (fallback for read-only data roots).
30
+ # ---------------------------------------------------------------------------
31
+
32
+ _SIDECAR_SUFFIX = ".bbox_index.parquet"
33
+ _CACHE_DIR = pathlib.Path.home() / ".cache" / "core_lens"
34
+
35
+
36
+ def _bbox_sidecar_path(static_path: str) -> pathlib.Path | None:
37
+ """Return the preferred path for the bbox index sidecar, or ``None`` for cloud URIs.
38
+
39
+ Tries the directory alongside ``static_path`` first; if that directory is
40
+ not writable (e.g. read-only data root), falls back to
41
+ ``~/.cache/core_lens/``.
42
+
43
+ Args:
44
+ static_path (str): Absolute local path to the static parquet file/directory.
45
+
46
+ Returns:
47
+ pathlib.Path | None: Sidecar path, or ``None`` if ``static_path`` is a cloud URI.
48
+ """
49
+ if is_cloud_uri(static_path):
50
+ return None
51
+ p = pathlib.Path(static_path)
52
+ # For a partitioned directory dataset, the sidecar lives inside the directory.
53
+ # For a single file, it lives next to it.
54
+ parent = p if p.is_dir() else p.parent
55
+ sidecar = parent / (p.name + _SIDECAR_SUFFIX)
56
+ # Check if the parent directory is writable.
57
+ if not parent.exists() or not parent.stat().st_mode & 0o200:
58
+ # Fallback to user cache dir.
59
+ import hashlib
60
+
61
+ fingerprint = hashlib.sha1(static_path.encode()).hexdigest()[:16]
62
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
63
+ sidecar = _CACHE_DIR / (fingerprint + _SIDECAR_SUFFIX)
64
+ return sidecar
65
+
66
+
67
+ def _read_bbox_sidecar(sidecar: pathlib.Path, static_path: str) -> pl.DataFrame | None:
68
+ """Read the sidecar if it exists and is newer than the static data.
69
+
70
+ Args:
71
+ sidecar (pathlib.Path): Candidate sidecar path.
72
+ static_path (str): Absolute local path to the static parquet file/directory.
73
+
74
+ Returns:
75
+ pl.DataFrame | None: The cached index, or ``None`` if the sidecar is
76
+ absent or stale.
77
+ """
78
+ if not sidecar.exists():
79
+ return None
80
+ try:
81
+ static_mtime = pathlib.Path(static_path).stat().st_mtime
82
+ sidecar_mtime = sidecar.stat().st_mtime
83
+ if sidecar_mtime < static_mtime:
84
+ logger.debug(
85
+ "Bbox sidecar {} is stale (older than {}), rebuilding.",
86
+ sidecar,
87
+ static_path,
88
+ )
89
+ return None
90
+ logger.debug("Loading bbox index from sidecar: {}", sidecar)
91
+ return pl.read_parquet(str(sidecar))
92
+ except Exception as exc:
93
+ logger.warning("Failed to read bbox sidecar {}: {}", sidecar, exc)
94
+ return None
95
+
96
+
97
+ def _write_bbox_sidecar(df: pl.DataFrame, sidecar: pathlib.Path) -> None:
98
+ """Write ``df`` to the sidecar file, silently ignoring any write errors.
99
+
100
+ Args:
101
+ df (pl.DataFrame): The bbox index DataFrame to persist.
102
+ sidecar (pathlib.Path): Destination sidecar path.
103
+ """
104
+ try:
105
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
106
+ df.write_parquet(str(sidecar))
107
+ logger.debug("Wrote bbox index sidecar: {}", sidecar)
108
+ except Exception as exc:
109
+ logger.debug("Could not write bbox index sidecar {}: {}", sidecar, exc)
110
+
17
111
 
18
112
  def resolve_path(path: str) -> str:
19
113
  """Return an absolute path or cloud URI string, resolving local relative paths against cwd.
@@ -79,14 +173,27 @@ def build_bbox_index(
79
173
  _so = storage_options or {}
80
174
  if bbox_cols is not None:
81
175
  logger.debug("Using pre-computed bbox columns: {}", bbox_cols)
82
- cols_to_read = key_cols + list(bbox_cols)
176
+ base_bbox_cols = list(dict.fromkeys(c.split(".")[0] for c in bbox_cols))
177
+ cols_to_read = key_cols + base_bbox_cols
83
178
  df = pl.read_parquet(
84
179
  static_path, columns=cols_to_read, storage_options=_so or None
85
180
  )
86
181
  minx_col, miny_col, maxx_col, maxy_col = bbox_cols
87
- return df.rename(
88
- {minx_col: "minx", miny_col: "miny", maxx_col: "maxx", maxy_col: "maxy"}
89
- )
182
+
183
+ for alias, col_path in zip(
184
+ ["minx", "miny", "maxx", "maxy"],
185
+ [minx_col, miny_col, maxx_col, maxy_col],
186
+ ):
187
+ parts = col_path.split(".")
188
+ if len(parts) == 1:
189
+ df = df.rename({col_path: alias})
190
+ else:
191
+ expr = pl.col(parts[0])
192
+ for p in parts[1:]:
193
+ expr = expr.struct.field(p)
194
+ df = df.with_columns(expr.alias(alias))
195
+
196
+ return df.select(key_cols + ["minx", "miny", "maxx", "maxy"])
90
197
 
91
198
  cols_to_read = key_cols + [geometry_col]
92
199
  if geometry_type == "latlon":
@@ -99,7 +206,15 @@ def build_bbox_index(
99
206
  + "Cannot compute bounds from separate lat/lon columns without bbox hints."
100
207
  )
101
208
 
102
- import pyarrow.dataset as ds
209
+ # --- Sidecar fast path (local paths only) --------------------------------
210
+ # Check for a pre-built index sidecar before doing the expensive WKB loop.
211
+ sidecar: pathlib.Path | None = None
212
+ if not is_cloud_uri(static_path):
213
+ sidecar = _bbox_sidecar_path(static_path)
214
+ if sidecar is not None:
215
+ cached = _read_bbox_sidecar(sidecar, static_path)
216
+ if cached is not None:
217
+ return cached
103
218
 
104
219
  logger.debug(
105
220
  "No pre-computed bbox found in {}; falling back to Shapely decoding",
@@ -133,17 +248,23 @@ def build_bbox_index(
133
248
  chunks.append(chunk)
134
249
 
135
250
  if chunks:
136
- return pl.concat(chunks)
251
+ result = pl.concat(chunks)
252
+ else:
253
+ empty_df = pl.read_parquet(
254
+ static_path, columns=key_cols, storage_options=_so or None
255
+ )
256
+ result = empty_df.with_columns(
257
+ pl.Series("minx", [], dtype=pl.Float64),
258
+ pl.Series("miny", [], dtype=pl.Float64),
259
+ pl.Series("maxx", [], dtype=pl.Float64),
260
+ pl.Series("maxy", [], dtype=pl.Float64),
261
+ )
137
262
 
138
- empty_df = pl.read_parquet(
139
- static_path, columns=key_cols, storage_options=_so or None
140
- )
141
- return empty_df.with_columns(
142
- pl.Series("minx", [], dtype=pl.Float64),
143
- pl.Series("miny", [], dtype=pl.Float64),
144
- pl.Series("maxx", [], dtype=pl.Float64),
145
- pl.Series("maxy", [], dtype=pl.Float64),
146
- )
263
+ # Persist the sidecar for future runs (local paths only, errors silenced).
264
+ if sidecar is not None:
265
+ _write_bbox_sidecar(result, sidecar)
266
+
267
+ return result
147
268
 
148
269
 
149
270
  def bbox_intersects_geometry(
@@ -213,8 +334,6 @@ def exact_spatial_filter(
213
334
  Raises:
214
335
  ValueError: If ``relationship`` is not one of the valid options.
215
336
  """
216
- import shapely
217
-
218
337
  logger.debug(
219
338
  "Refining {} candidates with exact spatial filter (relationship='{}')",
220
339
  len(candidates),
@@ -247,8 +366,6 @@ def exact_spatial_filter(
247
366
  else:
248
367
  geoms = shapely.from_wkt(geom_array)
249
368
 
250
- import numpy as np
251
-
252
369
  if relationship == "centroid":
253
370
  # Centroid mode: entity centroid must lie within the AoI geometry.
254
371
  # STRtree is overkill for a single-query containment test — building
@@ -320,8 +437,6 @@ def execute_spatial_join(
320
437
  pl.DataFrame: ``primary_df`` with additional columns
321
438
  ``{other_entity_name}_{col}`` appended for each ``agg`` entry.
322
439
  """
323
- import shapely
324
-
325
440
  logger.info(
326
441
  "Starting execute_spatial_join for other_entity_name={}", other_entity_name
327
442
  )
@@ -353,8 +468,6 @@ def execute_spatial_join(
353
468
  return primary_df
354
469
 
355
470
  # Compute bounding box of all primary geometries
356
- import numpy as np
357
-
358
471
  bnds = shapely.bounds(primary_geoms)
359
472
  valid_bnds = bnds[~np.isnan(bnds).any(axis=1)]
360
473
  if len(valid_bnds) > 0:
File without changes
File without changes
File without changes
File without changes