core-lens 0.1.dev142__tar.gz → 0.1.dev147__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 (92) hide show
  1. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_aoi.py +1 -1
  3. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_export.py +5 -5
  4. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_result.py +3 -3
  5. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_season.py +6 -3
  6. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_spatial.py +1 -1
  7. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_view.py +9 -9
  8. core_lens-0.1.dev147/docs/source/concepts.md +7 -0
  9. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/quickstart.md +2 -1
  10. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/stats.md +1 -1
  11. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/_version.py +2 -2
  12. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/aoi.py +24 -44
  13. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/entity.py +95 -61
  14. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/namespaces/plot.py +1 -1
  15. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/namespaces/stats.py +12 -10
  16. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/result.py +14 -19
  17. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/view.py +43 -26
  18. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/export/formats.py +1 -1
  19. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/utils/polars_utils.py +24 -3
  20. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/utils/season.py +4 -4
  21. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_aoi.py +4 -2
  22. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_entity.py +6 -6
  23. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_plot.py +15 -15
  24. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_result.py +3 -1
  25. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_view.py +10 -10
  26. core_lens-0.1.dev142/docs/source/concepts.md +0 -7
  27. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  28. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  29. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  30. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/pull_request_template.md +0 -0
  31. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/workflows/ci.yml +0 -0
  32. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/workflows/gh-pages.yml +0 -0
  33. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/workflows/pre-release.yml +0 -0
  34. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.github/workflows/release.yml +0 -0
  35. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.gitignore +0 -0
  36. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.gitmessage +0 -0
  37. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.pre-commit-config.yaml +0 -0
  38. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/.python-version +0 -0
  39. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/CONTRIBUTING.md +0 -0
  40. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/LICENSE +0 -0
  41. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/README.md +0 -0
  42. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/SKILLS.md +0 -0
  43. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/README.md +0 -0
  44. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_polars_utils.py +0 -0
  45. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/bench_schema.py +0 -0
  46. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/benchmarks/run_all.sh +0 -0
  47. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/Makefile +0 -0
  48. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/make.bat +0 -0
  49. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/conf.py +0 -0
  50. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/export.md +0 -0
  51. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/index.rst +0 -0
  52. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/intro.md +0 -0
  53. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/logging.md +0 -0
  54. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/plots.md +0 -0
  55. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/plugins.md +0 -0
  56. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/docs/source/queries.md +0 -0
  57. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/examples/demo_mws.py +0 -0
  58. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/examples/demo_tehsil.py +0 -0
  59. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/hooks/mypy.sh +0 -0
  60. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/hooks/no-parquet-outside-fixtures.sh +0 -0
  61. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/hooks/pytest.sh +0 -0
  62. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/pyproject.toml +0 -0
  63. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/__init__.py +0 -0
  64. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/__main__.py +0 -0
  65. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/__init__.py +0 -0
  66. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/base/namespaces/__init__.py +0 -0
  67. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/entities/__init__.py +0 -0
  68. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/entities/mws.py +0 -0
  69. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/entities/tehsil.py +0 -0
  70. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/entities/waterbody.py +0 -0
  71. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/export/__init__.py +0 -0
  72. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/py.typed +0 -0
  73. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/schema/__init__.py +0 -0
  74. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/schema/detection.py +0 -0
  75. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/schema/profile.py +0 -0
  76. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/utils/__init__.py +0 -0
  77. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/utils/paths.py +0 -0
  78. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/src/core_lens/utils/spatial.py +0 -0
  79. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/fixtures/generate_fixtures.py +0 -0
  80. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/conftest.py +0 -0
  81. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_entities.py +0 -0
  82. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_export.py +0 -0
  83. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_main.py +0 -0
  84. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_polars_utils.py +0 -0
  85. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_profile.py +0 -0
  86. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_schema_detection.py +0 -0
  87. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_schema_profile.py +0 -0
  88. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_season.py +0 -0
  89. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_season_config.py +0 -0
  90. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_spatial.py +0 -0
  91. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/tests/unit/test_stats.py +0 -0
  92. {core_lens-0.1.dev142 → core_lens-0.1.dev147}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-lens
3
- Version: 0.1.dev142
3
+ Version: 0.1.dev147
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
@@ -116,7 +116,7 @@ view = aoi_bbox.mws
116
116
 
117
117
  t1 = time.perf_counter()
118
118
  print(f"First access : {(t1 - t0) * 1000:.2f} ms")
119
- print(f"Keys shape : {view.keys.shape}")
119
+ print(f"Keys shape : {view.keys.collect().shape}")
120
120
 
121
121
  # Second access — should hit _scoped cache (near-zero cost).
122
122
  t0 = time.perf_counter()
@@ -45,8 +45,8 @@ aoi_small = AoI(DATA_ROOT, bbox=SMALL_BBOX)
45
45
  result_all = aoi_all.mws.static # full dataset — geometry present
46
46
  result_small = aoi_small.mws.static # small subset
47
47
 
48
- print(f"All result : {result_all.data.shape}")
49
- print(f"Small result : {result_small.data.shape}")
48
+ print(f"All result : {result_all.df().shape}")
49
+ print(f"Small result : {result_small.df().shape}")
50
50
 
51
51
 
52
52
  def _section(title: str) -> None:
@@ -81,9 +81,9 @@ with tempfile.TemporaryDirectory() as tmpdir:
81
81
  # Strip geometry (and nested bbox) to pass has_geometry=False and allow CSV export.
82
82
  geom_col = result_small.entity.geometry_col
83
83
  drop_cols = [geom_col]
84
- if "bbox" in result_small.data.columns:
84
+ if "bbox" in result_small.df().columns:
85
85
  drop_cols.append("bbox")
86
- df_no_geom = result_small.data.drop(drop_cols)
86
+ df_no_geom = result_small.df().drop(drop_cols)
87
87
  result_no_geom = result_small._replace(data=df_no_geom, has_geometry=False)
88
88
 
89
89
  REPS_IO = 5
@@ -99,7 +99,7 @@ with tempfile.TemporaryDirectory() as tmpdir:
99
99
  )
100
100
 
101
101
  # All dataset.
102
- df_all_no_geom = result_all.data.drop(geom_col)
102
+ df_all_no_geom = result_all.df().drop(geom_col)
103
103
  result_all_no_geom = result_all._replace(data=df_all_no_geom, has_geometry=False)
104
104
  out_all = tmp / "bench_all.parquet"
105
105
  t0 = time.perf_counter()
@@ -41,7 +41,7 @@ aoi_small = AoI(DATA_ROOT, bbox=SMALL_BBOX)
41
41
  # Materialise once — all benchmarks below operate on this result.
42
42
  result = aoi.mws.static
43
43
  result_small = aoi_small.mws.static
44
- print(f"Dataset: {result.data.shape[0]} MWS, {result.data.shape[1]} columns")
44
+ print(f"Dataset: {result.df().shape[0]} MWS, {result.df().shape[1]} columns")
45
45
 
46
46
 
47
47
  def _section(title: str) -> None:
@@ -151,7 +151,7 @@ agg_result = result_annual.aggregate(
151
151
  )
152
152
  t1 = time.perf_counter()
153
153
  print(f"aggregate(no by) : {(t1 - t0) * 1000:.2f} ms")
154
- print(f"Shape : {agg_result.data.shape}")
154
+ print(f"Shape : {agg_result.df().shape}")
155
155
 
156
156
 
157
157
  # ── 8. Result.aggregate(by='year') ──────────────────────────────────────────────
@@ -165,7 +165,7 @@ agg_year = result_annual.aggregate(
165
165
  )
166
166
  t1 = time.perf_counter()
167
167
  print(f"aggregate(year) : {(t1 - t0) * 1000:.2f} ms")
168
- print(f"Shape : {agg_year.data.shape}")
168
+ print(f"Shape : {agg_year.df().shape}")
169
169
 
170
170
 
171
171
  # ── Cleanup ───────────────────────────────────────────────────────────────
@@ -25,6 +25,7 @@ import time
25
25
  from datetime import date, timedelta
26
26
 
27
27
  import polars as pl
28
+ from typing import cast
28
29
 
29
30
  from core_lens.aoi import SeasonConfig
30
31
  from core_lens.utils.season import add_temporal_columns, resolve_time_filter
@@ -167,9 +168,9 @@ t0 = time.perf_counter()
167
168
  result_df = add_temporal_columns(FORTNIGHTLY_DF, "date", cfg)
168
169
  t1 = time.perf_counter()
169
170
  print(f"add_temporal_columns: {(t1 - t0) * 1000:.2f} ms")
170
- print(f"Output shape : {result_df.shape}")
171
+ print(f"Output shape : {cast(pl.DataFrame, result_df).shape}")
171
172
  print(
172
- f"New columns added : {[c for c in result_df.columns if c not in FORTNIGHTLY_DF.columns]}"
173
+ f"New columns added : {[c for c in cast(pl.DataFrame, result_df).columns if c not in FORTNIGHTLY_DF.columns]}"
173
174
  )
174
175
 
175
176
  # Second call — all columns already present, should be near-zero.
@@ -186,6 +187,8 @@ t0 = time.perf_counter()
186
187
  filtered = result_df.filter(expr)
187
188
  t1 = time.perf_counter()
188
189
  print(f"filter(kharif 2022) : {(t1 - t0) * 1000:.2f} ms")
189
- print(f"Rows matched : {len(filtered):,} / {len(result_df):,}")
190
+ print(
191
+ f"Rows matched : {len(cast(pl.DataFrame, filtered)):,} / {len(cast(pl.DataFrame, result_df)):,}"
192
+ )
190
193
 
191
194
  print("\n✓ bench_season.py complete")
@@ -180,7 +180,7 @@ view = entity.spatial_filter(bbox=SMALL_BBOX)
180
180
 
181
181
  t1 = time.perf_counter()
182
182
  print(f"spatial_filter (bbox) : {(t1 - t0) * 1000:.2f} ms")
183
- print(f"View keys : {view.keys.shape}")
183
+ print(f"View keys : {view.keys.collect().shape}")
184
184
 
185
185
  t0 = time.perf_counter()
186
186
  view2 = entity.spatial_filter(geometry=small_poly)
@@ -50,7 +50,7 @@ t0 = time.perf_counter()
50
50
  view_all = aoi.mws
51
51
  t1 = time.perf_counter()
52
52
  print(f"aoi.mws : {(t1 - t0) * 1000:.2f} ms")
53
- print(f"Keys : {view_all.keys.shape}")
53
+ print(f"Keys : {view_all.keys.collect().shape}")
54
54
 
55
55
 
56
56
  # ── 2. View.where() — single filter ──────────────────────────────────────────
@@ -59,7 +59,7 @@ t0 = time.perf_counter()
59
59
  view_bihar = view_all.where(state="Bihar")
60
60
  t1 = time.perf_counter()
61
61
  print(f"where(state) : {(t1 - t0) * 1000:.2f} ms")
62
- print(f"Bihar keys : {view_bihar.keys.shape}")
62
+ print(f"Bihar keys : {view_bihar.keys.collect().shape}")
63
63
 
64
64
 
65
65
  # ── 3. View.where() — composite AND filter ────────────────────────────────────
@@ -68,7 +68,7 @@ t0 = time.perf_counter()
68
68
  view_banka = view_all.where(state="Bihar", district="Banka", tehsil="Banka")
69
69
  t1 = time.perf_counter()
70
70
  print(f"where(3 cols) : {(t1 - t0) * 1000:.2f} ms")
71
- print(f"Banka keys : {view_banka.keys.shape}")
71
+ print(f"Banka keys : {view_banka.keys.collect().shape}")
72
72
 
73
73
 
74
74
  # ── 4. View.spatial_filter() — further narrow ─────────────────────────────────
@@ -78,7 +78,7 @@ t0 = time.perf_counter()
78
78
  view_spatial = view_all.spatial_filter(bbox=SMALL_BBOX)
79
79
  t1 = time.perf_counter()
80
80
  print(f"spatial_filter : {(t1 - t0) * 1000:.2f} ms")
81
- print(f"Spatial keys : {view_spatial.keys.shape}")
81
+ print(f"Spatial keys : {view_spatial.keys.collect().shape}")
82
82
 
83
83
 
84
84
  # ── 5. View.between() — date range (no I/O) ───────────────────────────────────
@@ -112,14 +112,14 @@ t0 = time.perf_counter()
112
112
  result_static = aoi.mws.static
113
113
  t1 = time.perf_counter()
114
114
  print(f"static (all) : {(t1 - t0) * 1000:.2f} ms")
115
- print(f"Shape : {result_static.data.shape}")
115
+ print(f"Shape : {result_static.df().shape}")
116
116
 
117
117
  _section("8. aoi_small.mws.static [small-bbox materialisation]")
118
118
  t0 = time.perf_counter()
119
119
  result_small = aoi_small.mws.static
120
120
  t1 = time.perf_counter()
121
121
  print(f"static (small) : {(t1 - t0) * 1000:.2f} ms")
122
- print(f"Shape : {result_small.data.shape}")
122
+ print(f"Shape : {result_small.df().shape}")
123
123
 
124
124
 
125
125
  # ── 9. Chained pipeline: where → spatial_filter → static ─────────────────────
@@ -128,7 +128,7 @@ t0 = time.perf_counter()
128
128
  result_chain = aoi.mws.spatial_filter(bbox=SMALL_BBOX).static
129
129
  t1 = time.perf_counter()
130
130
  print(f"Chain : {(t1 - t0) * 1000:.2f} ms")
131
- print(f"Shape : {result_chain.data.shape}")
131
+ print(f"Shape : {result_chain.df().shape}")
132
132
 
133
133
 
134
134
  # ── 10. annual materialisation ──────────────────────────────────────────────────
@@ -137,7 +137,7 @@ t0 = time.perf_counter()
137
137
  result_annual = aoi_small.mws.between("2018-01-01", "2023-12-31").annual
138
138
  t1 = time.perf_counter()
139
139
  print(f"annual : {(t1 - t0) * 1000:.2f} ms")
140
- print(f"Shape : {result_annual.data.shape}")
140
+ print(f"Shape : {result_annual.df().shape}")
141
141
 
142
142
 
143
143
  # ── 11. fortnightly materialisation ───────────────────────────────────────────
@@ -146,7 +146,7 @@ t0 = time.perf_counter()
146
146
  result_fn = aoi_small.mws.between(season=Season.KHARIF, year=2022).fortnightly
147
147
  t1 = time.perf_counter()
148
148
  print(f"fortnightly : {(t1 - t0) * 1000:.2f} ms")
149
- print(f"Shape : {result_fn.data.shape}")
149
+ print(f"Shape : {result_fn.df().shape}")
150
150
 
151
151
 
152
152
  # ── Cleanup ───────────────────────────────────────────────────────────────────
@@ -0,0 +1,7 @@
1
+ # Core Concepts
2
+
3
+ CoreLens is built on three main layers:
4
+
5
+ 1. **AoI (Area of Interest)**: The primary entry point. It represents a spatial boundary and acts as a gateway to all registered entities that intersect that boundary.
6
+ 2. **View**: A lazy, immutable query definition. Filtering by attributes (`where`), space (`spatial_filter`), or time (`between`) returns a new `View` without reading any Parquet data.
7
+ 3. **Result**: The lazy computation graph. Accessing `.static`, `.annual`, or `.fortnightly` on a `View` resolves the query plan and returns a `Result` object wrapping a Polars `LazyFrame`. Data is not actually read until you materialise it by calling `.df()` or `.gdf()`. This delays execution and reduces memory footprint, allowing the Polars streaming engine (automatically routed to the GPU if RAPIDS `cudf-polars` is installed) to optimize the query globally. All statistical operations, aggregations, and plotting are done on `Result` objects.
@@ -43,8 +43,9 @@ aoi_specific = AoI(
43
43
  # You can now access entities scoped to this AoI:
44
44
  mws_view = aoi.mws
45
45
 
46
- # Materialise the static data (GeoDataFrame)
46
+ # Resolve the static data query
47
47
  static_result = mws_view.static
48
+ # Materialise as a GeoDataFrame (or use .df() for a Polars DataFrame)
48
49
  gdf = static_result.gdf()
49
50
 
50
51
  # Plot the AoI and its microwatersheds on an interactive map
@@ -1,6 +1,6 @@
1
1
  # Statistical Analysis
2
2
 
3
- The `result.stats` namespace provides powerful analytical tools for geospatial and timeseries data. All statistical methods return a new `Result` object with computed `data` and populated `metadata`.
3
+ The `result.stats` namespace provides powerful analytical tools for geospatial and timeseries data. All statistical methods return a new `Result` object with the computed lazy `data` and populated `metadata`.
4
4
 
5
5
  ## Descriptive Statistics
6
6
 
@@ -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.dev142'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev142')
21
+ __version__ = version = '0.1.dev147'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev147')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -20,6 +20,7 @@ from loguru import logger
20
20
 
21
21
  from core_lens.base.entity import BaseEntity, EntityValidationError
22
22
  from core_lens.utils.paths import is_cloud_uri
23
+ from core_lens.utils.polars_utils import cached_read_schema
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from core_lens.base.view import View
@@ -219,7 +220,7 @@ def _cached_resolve_boundary(
219
220
 
220
221
  lf = pl.scan_parquet(static_path, storage_options=storage_options or None)
221
222
  filter_expr = pl.lit(True)
222
- schema_types = lf.collect_schema()
223
+ schema_types = cached_read_schema(static_path, storage_options)
223
224
  for col, val in entity_kwargs.items():
224
225
  if col in schema.key_cols or col in schema.extra_static_cols:
225
226
  is_list_col = isinstance(schema_types.get(col), pl.List)
@@ -383,19 +384,16 @@ class AoI:
383
384
  # Entity instances are created lazily on first access via _get_entity().
384
385
  self._entity_instances: dict[str, BaseEntity] = {}
385
386
 
386
- # Initialise boundary-entity cache. Only populated by the
387
- # named-boundary path (_resolve_named_boundary); bbox= / geometry=
388
- # construction leaves these as None so __getattr__ falls through
389
- # to the normal spatial_filter path for every entity.
390
- self._boundary_entity_name: str | None = None
391
- self._boundary_keys: pl.DataFrame | None = None
387
+ # Initialise boundary kwargs and lazily computed geometry.
388
+ self._boundary_kwargs: dict[str, str | list[str]] | None = None
389
+ self._geometry: shapely.Geometry | None = None
392
390
 
393
391
  if geometry is not None:
394
- self.geometry: "shapely.Geometry" = geometry
392
+ self._geometry = geometry
395
393
  elif bbox is not None:
396
- self.geometry = _bbox_to_polygon(bbox)
394
+ self._geometry = _bbox_to_polygon(bbox)
397
395
  else:
398
- self.geometry = self._resolve_named_boundary(entity_kwargs)
396
+ self._boundary_kwargs = entity_kwargs
399
397
 
400
398
  # Entity views are created lazily on demand in __getattr__.
401
399
  self._scoped: dict[str, "View"] = {}
@@ -406,6 +404,17 @@ class AoI:
406
404
  if validate_all:
407
405
  self.validate()
408
406
 
407
+ @property
408
+ def geometry(self) -> "shapely.Geometry":
409
+ """The resolved boundary of this AoI as a Shapely geometry."""
410
+ if self._geometry is None:
411
+ if self._boundary_kwargs is not None:
412
+ self._geometry = self._resolve_named_boundary(self._boundary_kwargs)
413
+ else:
414
+ # Should not happen if initialization was correct
415
+ raise ValueError("No boundary defined.")
416
+ return self._geometry
417
+
409
418
  @property
410
419
  def current_season(self) -> str:
411
420
  """The season name for today's date under the AoI's SeasonConfig.
@@ -466,7 +475,7 @@ class AoI:
466
475
  list(overlay.key_cols) if hasattr(overlay, "key_cols") else []
467
476
  )
468
477
  arrow_table = _wkb_to_arrow_table(
469
- overlay.data, geom_col, extra_cols=key_cols
478
+ overlay.df(), geom_col, extra_cols=key_cols
470
479
  )
471
480
  overlay_layer = lonboard.PolygonLayer(
472
481
  arrow_table,
@@ -538,23 +547,12 @@ class AoI:
538
547
  if name in _REGISTRY:
539
548
  if name not in self._scoped:
540
549
  entity = self._get_entity(name)
541
- if name == self._boundary_entity_name:
542
- # This entity *defined* the AoI boundary — we already
543
- # have its exact matching keys from
544
- # _resolve_named_boundary. Skip the bbox-index build
545
- # and geometric predicate entirely.
546
- from core_lens.base.view import View
547
-
548
- assert (
549
- self._boundary_keys is not None
550
- ) # set by _resolve_named_boundary
551
- view = View(
552
- keys=self._boundary_keys,
553
- entity=entity,
554
- entity_name=name,
555
- )
550
+
551
+ if self._boundary_kwargs is not None:
552
+ view = entity.where(**self._boundary_kwargs)
556
553
  else:
557
554
  view = entity.spatial_filter(geometry=self.geometry)
555
+
558
556
  view._season_config = self.seasons
559
557
  self._scoped[name] = view
560
558
  return self._scoped[name]
@@ -608,24 +606,6 @@ class AoI:
608
606
  # Reconstruct the boundary geometry from cached WKB bytes (fast).
609
607
  geometry: shapely.Geometry = shapely.from_wkb(geom_wkb)
610
608
 
611
- # Reconstruct _boundary_keys DataFrame from cached key_rows.
612
- # We need the key column names — get them from the boundary entity.
613
- boundary_entity_cls = _REGISTRY.get(boundary_entity_name)
614
- if boundary_entity_cls is not None:
615
- key_cols = boundary_entity_cls().key_cols
616
- self._boundary_entity_name = boundary_entity_name
617
- self._boundary_keys = pl.DataFrame(
618
- {col: [row[i] for row in key_rows] for i, col in enumerate(key_cols)}
619
- )
620
- else:
621
- # Registry changed since cache was populated — defensive fallback.
622
- logger.warning(
623
- "Boundary cache returned entity name '{}' not found in current registry.",
624
- boundary_entity_name,
625
- )
626
- self._boundary_entity_name = None
627
- self._boundary_keys = None
628
-
629
609
  return geometry
630
610
 
631
611
  @classmethod
@@ -29,6 +29,7 @@ from core_lens.utils.spatial import (
29
29
  build_bbox_index,
30
30
  exact_spatial_filter,
31
31
  )
32
+ from core_lens.utils.polars_utils import cached_read_schema
32
33
 
33
34
  if TYPE_CHECKING:
34
35
  import shapely
@@ -371,6 +372,20 @@ class BaseEntity(ABC):
371
372
  )
372
373
  return self._schema_profile
373
374
 
375
+ @property
376
+ def geometry_lazy(self) -> pl.LazyFrame:
377
+ """A cached LazyFrame representing the geometry of this entity.
378
+
379
+ Contains only the key columns and the geometry column. Used to avoid
380
+ repeatedly scanning and selecting geometries in downstream operations.
381
+ """
382
+ if not hasattr(self, "_geometry_lazy"):
383
+ self._geometry_lazy = pl.scan_parquet(
384
+ self._resolve(self.static_path),
385
+ storage_options=self._storage_options or None,
386
+ ).select(self.key_cols + [self.schema_profile.geometry_col])
387
+ return self._geometry_lazy
388
+
374
389
  @property
375
390
  def _index(self) -> pl.DataFrame:
376
391
  """In-memory bounding-box index for spatial pre-filtering.
@@ -434,7 +449,7 @@ class BaseEntity(ABC):
434
449
  )
435
450
 
436
451
  static = self._resolve(self.static_path)
437
- schema = pl.read_parquet_schema(static)
452
+ schema = cached_read_schema(static, self._storage_options)
438
453
 
439
454
  attr_kwargs = {k: v for k, v in kwargs.items() if k in schema}
440
455
  entity_kwargs = {k: v for k, v in kwargs.items() if k not in schema}
@@ -453,77 +468,96 @@ class BaseEntity(ABC):
453
468
  f"Available columns: {sorted(schema)}."
454
469
  )
455
470
 
456
- # --- Attribute filter -----------------------------------------------
471
+ # --- Combine attribute and spatial filters into a single lazy pipeline ---
472
+ lf = pl.scan_parquet(static)
473
+
457
474
  if attr_kwargs:
458
475
  filter_expr = pl.lit(True)
459
476
  for col, val in attr_kwargs.items():
460
477
  filter_expr = filter_expr & (pl.col(col) == val)
461
- keys = (
462
- pl.scan_parquet(static)
463
- .filter(filter_expr)
464
- .select(self.key_cols)
465
- .collect()
466
- )
478
+ lf = lf.filter(filter_expr)
479
+
480
+ if not entity_kwargs:
481
+ keys = lf.select(self.key_cols).collect()
467
482
  else:
468
- # No attribute filter: start with all entities.
469
- keys = pl.scan_parquet(static).select(self.key_cols).collect()
470
-
471
- # --- Spatial entity lookups -----------------------------------------
472
- for entity_kwarg_name, entity_kwarg_val in entity_kwargs.items():
473
- other_entity = _REGISTRY[entity_kwarg_name](data_root=self._data_root)
474
- other_profile = other_entity.schema_profile
475
- other_static = other_entity._resolve(other_entity.static_path)
483
+ import shapely
476
484
  import shapely.wkb as swkb
485
+ import shapely.wkt as swkt
477
486
  import shapely.ops as sops
478
-
479
- # Find the geometry of the named entity.
480
- lf = pl.scan_parquet(other_static)
481
- match_expr = pl.lit(False)
482
- for col in other_entity.key_cols + list(other_profile.extra_static_cols):
483
- if col in pl.read_parquet_schema(other_static):
484
- match_expr = match_expr | (pl.col(col) == entity_kwarg_val)
485
- matched = (
486
- lf.filter(match_expr).select([other_profile.geometry_col]).collect()
487
- )
488
-
489
- if matched.is_empty():
490
- logger.error(
491
- "BaseEntity.where failed: No rows matched {}={!r} in {}",
492
- entity_kwarg_name,
493
- entity_kwarg_val,
494
- other_entity.static_path,
487
+ import numpy as np
488
+ from core_lens.utils.spatial import bbox_intersects_geometry
489
+
490
+ lookup_geoms = []
491
+ for entity_kwarg_name, entity_kwarg_val in entity_kwargs.items():
492
+ other_entity = _REGISTRY[entity_kwarg_name](data_root=self._data_root)
493
+ other_profile = other_entity.schema_profile
494
+ other_static = other_entity._resolve(other_entity.static_path)
495
+
496
+ other_lf = pl.scan_parquet(other_static)
497
+ match_expr = pl.lit(False)
498
+ other_schema = cached_read_schema(
499
+ other_static, other_entity._storage_options
495
500
  )
496
- raise ValueError(
497
- f"BaseEntity.where: No rows matched {entity_kwarg_name}={entity_kwarg_val!r} "
498
- f"in {other_entity.static_path!r}."
501
+ for col in other_entity.key_cols + list(
502
+ other_profile.extra_static_cols
503
+ ):
504
+ if col in other_schema:
505
+ match_expr = match_expr | (pl.col(col) == entity_kwarg_val)
506
+ matched = (
507
+ other_lf.filter(match_expr)
508
+ .select([other_profile.geometry_col])
509
+ .collect()
499
510
  )
500
511
 
501
- raw_geoms = matched[other_profile.geometry_col].to_list()
502
- if other_profile.geometry_type == "wkb":
503
- geoms = [swkb.loads(v) for v in raw_geoms]
512
+ if matched.is_empty():
513
+ logger.error(
514
+ "BaseEntity.where failed: No rows matched {}={!r} in {}",
515
+ entity_kwarg_name,
516
+ entity_kwarg_val,
517
+ other_entity.static_path,
518
+ )
519
+ raise ValueError(
520
+ f"BaseEntity.where: No rows matched {entity_kwarg_name}={entity_kwarg_val!r} "
521
+ f"in {other_entity.static_path!r}."
522
+ )
523
+
524
+ raw_geoms = matched[other_profile.geometry_col].to_list()
525
+ if other_profile.geometry_type == "wkb":
526
+ geoms = [swkb.loads(v) for v in raw_geoms]
527
+ else:
528
+ geoms = [swkt.loads(v) for v in raw_geoms]
529
+ lookup_geom = sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
530
+ lookup_geoms.append(lookup_geom)
531
+
532
+ candidates = self._index
533
+ for geom in lookup_geoms:
534
+ candidates = bbox_intersects_geometry(candidates, geom)
535
+
536
+ if candidates.is_empty():
537
+ keys = candidates.select(self.key_cols)
504
538
  else:
505
- import shapely.wkt as swkt
506
-
507
- geoms = [swkt.loads(v) for v in raw_geoms]
508
- lookup_geom = sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
509
-
510
- # Spatial filter: narrow keys to those whose centroid is within the geometry.
511
- from core_lens.utils.spatial import (
512
- bbox_intersects_geometry,
513
- exact_spatial_filter,
514
- )
515
-
516
- current_keys = self._index.join(keys, on=self.key_cols, how="inner")
517
- candidates = bbox_intersects_geometry(current_keys, lookup_geom)
518
- keys = exact_spatial_filter(
519
- candidates=candidates,
520
- static_path=static,
521
- key_cols=self.key_cols,
522
- geometry_col=self.schema_profile.geometry_col,
523
- geometry_type=self.schema_profile.geometry_type,
524
- aoi_geometry=lookup_geom,
525
- relationship="centroid",
526
- )
539
+ lf = lf.select(self.key_cols + [self.schema_profile.geometry_col]).join(
540
+ candidates.select(self.key_cols).lazy(),
541
+ on=self.key_cols,
542
+ how="semi",
543
+ )
544
+ df = lf.collect()
545
+
546
+ if df.is_empty():
547
+ keys = df.select(self.key_cols)
548
+ else:
549
+ geom_array = df[self.schema_profile.geometry_col].to_numpy()
550
+ if self.schema_profile.geometry_type == "wkb":
551
+ decoded_geoms = shapely.from_wkb(geom_array)
552
+ else:
553
+ decoded_geoms = shapely.from_wkt(geom_array)
554
+
555
+ centroids = shapely.centroid(decoded_geoms)
556
+ mask = np.ones(len(centroids), dtype=bool)
557
+ for geom in lookup_geoms:
558
+ mask = mask & shapely.contains(geom, centroids)
559
+
560
+ keys = df.filter(mask).select(self.key_cols)
527
561
 
528
562
  entity_name = _entity_name(type(self))
529
563
  return View(keys=keys, entity=self, entity_name=entity_name)
@@ -291,7 +291,7 @@ class PlotNamespace:
291
291
  if subplot_col is not None:
292
292
  extra.append(subplot_col)
293
293
 
294
- df = res.data
294
+ df = res.df()
295
295
 
296
296
  if subplot_col is not None:
297
297
  # Render most-recent unique value of subplot_on only.
@@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, cast
10
10
  import numpy as np
11
11
  import polars as pl
12
12
 
13
- from core_lens.utils.polars_utils import collect_lf
13
+ from core_lens.utils.polars_utils import collect_lf, cached_read_schema
14
14
 
15
15
  if TYPE_CHECKING:
16
16
  from core_lens.base.result import Result
@@ -164,7 +164,7 @@ class StatsNamespace:
164
164
  return [c for c in df.columns if df[c].dtype.is_numeric()]
165
165
 
166
166
  def _year_col(self) -> str | None:
167
- if "year" in self._r.data.columns:
167
+ if "year" in self._r.df().columns:
168
168
  return "year"
169
169
  profile = self._r.entity.schema_profile
170
170
  return profile.annual_time_col
@@ -190,7 +190,7 @@ class StatsNamespace:
190
190
  ValueError: If ``by`` is not ``\"column\"`` or ``\"entity\"``.
191
191
 
192
192
  """
193
- df = self._r.data
193
+ df = self._r.df()
194
194
  cols = columns if columns is not None else self._numeric_cols(df)
195
195
 
196
196
  if by == "column":
@@ -261,7 +261,7 @@ class StatsNamespace:
261
261
 
262
262
  import scipy.stats as sp
263
263
 
264
- df = self._r.data
264
+ df = self._r.df()
265
265
  n_obs = len(df)
266
266
  rows: list[dict[str, Any]] = []
267
267
 
@@ -327,7 +327,7 @@ class StatsNamespace:
327
327
  """
328
328
  import scipy.stats as sp
329
329
 
330
- df = self._r.data
330
+ df = self._r.df()
331
331
  all_vals = df[column].drop_nulls().to_numpy().astype(float)
332
332
 
333
333
  # auto-select
@@ -480,7 +480,7 @@ class StatsNamespace:
480
480
 
481
481
  import scipy.stats as sp
482
482
 
483
- df = self._r.data
483
+ df = self._r.df()
484
484
  key = self._r.key_cols[0]
485
485
  year_col = self._year_col()
486
486
  if year_col is None:
@@ -580,7 +580,7 @@ class StatsNamespace:
580
580
  ValueError: If ``mode``, ``method``, or observation count invalid.
581
581
 
582
582
  """
583
- df = self._r.data
583
+ df = self._r.df()
584
584
  key = self._r.key_cols[0]
585
585
 
586
586
  if mode == "cross_sectional":
@@ -856,7 +856,7 @@ class StatsNamespace:
856
856
  f"Valid options: {[e.name for e in SimilarityMethod]}."
857
857
  )
858
858
 
859
- df = self._r.data
859
+ df = self._r.df()
860
860
  key = self._r.key_cols[0]
861
861
 
862
862
  # --- Build feature DataFrame ------------------------------------------
@@ -903,11 +903,13 @@ class StatsNamespace:
903
903
  except FileNotFoundError:
904
904
  continue
905
905
 
906
- schema = pl.read_parquet_schema(abs_path)
906
+ schema = cached_read_schema(abs_path, entity._storage_options)
907
907
  if col_name not in schema:
908
908
  continue # column doesn't exist in this file
909
909
 
910
- col_lf = pl.scan_parquet(abs_path)
910
+ col_lf = pl.scan_parquet(
911
+ abs_path, storage_options=entity._storage_options or None
912
+ )
911
913
 
912
914
  # Apply year filter.
913
915
  if "year" in filter_dict: