core-lens 0.1.dev180__tar.gz → 0.1.dev184__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 (94) hide show
  1. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/PKG-INFO +2 -2
  2. core_lens-0.1.dev184/SKILLS.md +242 -0
  3. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/export.md +1 -6
  4. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/plugins.md +2 -0
  5. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/queries.md +2 -7
  6. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/quickstart.md +2 -6
  7. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/stats.md +10 -18
  8. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/_version.py +2 -2
  9. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/aoi.py +4 -2
  10. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/entity.py +2 -2
  11. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/schema/detection.py +19 -1
  12. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/schema/profile.py +11 -0
  13. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/utils/polars_utils.py +28 -1
  14. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/utils/spatial.py +3 -2
  15. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/usage.md +12 -13
  16. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/uv.lock +485 -392
  17. core_lens-0.1.dev180/SKILLS.md +0 -259
  18. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  19. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  20. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  21. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/pull_request_template.md +0 -0
  22. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/workflows/ci.yml +0 -0
  23. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/workflows/gh-pages.yml +0 -0
  24. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/workflows/pre-release.yml +0 -0
  25. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.github/workflows/release.yml +0 -0
  26. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.gitignore +0 -0
  27. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.gitmessage +0 -0
  28. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.pre-commit-config.yaml +0 -0
  29. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/.python-version +0 -0
  30. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/CONTRIBUTING.md +0 -0
  31. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/LICENSE +0 -0
  32. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/README.md +0 -0
  33. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/README.md +0 -0
  34. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_aoi.py +0 -0
  35. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_entity.py +0 -0
  36. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_export.py +0 -0
  37. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_polars_utils.py +0 -0
  38. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_result.py +0 -0
  39. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_schema.py +0 -0
  40. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_season.py +0 -0
  41. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_spatial.py +0 -0
  42. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/bench_view.py +0 -0
  43. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/benchmarks/run_all.sh +0 -0
  44. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/Makefile +0 -0
  45. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/make.bat +0 -0
  46. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/concepts.md +0 -0
  47. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/conf.py +0 -0
  48. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/index.rst +0 -0
  49. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/intro.md +0 -0
  50. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/logging.md +0 -0
  51. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/docs/source/plots.md +0 -0
  52. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/examples/demo_mws.py +0 -0
  53. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/examples/demo_tehsil.py +0 -0
  54. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/hooks/mypy.sh +0 -0
  55. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/hooks/no-parquet-outside-fixtures.sh +0 -0
  56. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/hooks/pytest.sh +0 -0
  57. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/pyproject.toml +0 -0
  58. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/__init__.py +0 -0
  59. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/__main__.py +0 -0
  60. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/__init__.py +0 -0
  61. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/namespaces/__init__.py +0 -0
  62. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/namespaces/plot.py +0 -0
  63. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/namespaces/stats.py +0 -0
  64. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/result.py +0 -0
  65. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/base/view.py +0 -0
  66. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/entities/__init__.py +0 -0
  67. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/entities/mws.py +0 -0
  68. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/entities/tehsil.py +0 -0
  69. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/entities/waterbody.py +0 -0
  70. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/export/__init__.py +0 -0
  71. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/export/formats.py +0 -0
  72. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/py.typed +0 -0
  73. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/schema/__init__.py +0 -0
  74. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/utils/__init__.py +0 -0
  75. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/utils/paths.py +0 -0
  76. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/src/core_lens/utils/season.py +0 -0
  77. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/fixtures/generate_fixtures.py +0 -0
  78. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/conftest.py +0 -0
  79. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_aoi.py +0 -0
  80. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_entities.py +0 -0
  81. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_entity.py +0 -0
  82. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_export.py +0 -0
  83. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_main.py +0 -0
  84. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_plot.py +0 -0
  85. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_polars_utils.py +0 -0
  86. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_profile.py +0 -0
  87. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_result.py +0 -0
  88. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_schema_detection.py +0 -0
  89. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_schema_profile.py +0 -0
  90. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_season.py +0 -0
  91. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_season_config.py +0 -0
  92. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_spatial.py +0 -0
  93. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_stats.py +0 -0
  94. {core_lens-0.1.dev180 → core_lens-0.1.dev184}/tests/unit/test_view.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: core-lens
3
- Version: 0.1.dev180
3
+ Version: 0.1.dev184
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
@@ -0,0 +1,242 @@
1
+ ---
2
+ name: core-lens
3
+ description: >
4
+ A Python library for querying, analysing, and visualising geospatial Earth science data.
5
+ Provides a unified interface over microwatersheds (MWS), administrative boundaries, and pluggable domain entities.
6
+ Use this skill to understand how to interact with AoI (Area of Interest), Views, Results, and Statistical analysis.
7
+ ---
8
+
9
+ # Agent Instructions: Core-Lens Library
10
+
11
+ You are an AI Agent tasked with writing or debugging code using the `core-lens` library. `core-lens` is a high-performance Python library designed for querying, filtering, and manipulating large-scale geospatial datasets using deferred execution with **Polars**.
12
+
13
+ **Read these instructions carefully before writing any code.**
14
+
15
+ ## 1. Core Architectural Constraints
16
+
17
+ - **Area of Interest (AoI) is the Primary Entry Point**: Never load parquet files directly. Always create an `AoI` object to define the geographical scope and directory context.
18
+ - **Lazy Evaluation**: `core-lens` is lazy by default. Accessing an entity like `aoi.mws` creates a `View`. No data is loaded into memory until you call a materialisation method (`.static`, `.annual`, or `.sub_annual`).
19
+ - **Polars Over Pandas**: `core-lens` uses Polars for zero-copy operations. Only convert to GeoPandas (`.gdf()`) when strictly necessary for heavy geometric processing.
20
+ - **Separation of Concerns**:
21
+ - `AoI`: Filters boundaries, resolves geography, scopes entities, and handles time constraints.
22
+ - `Result`: Produced by materializing a view. Handles all computation, spatial joins, aggregations, stats, and plotting.
23
+ - `Export`: Separate namespace (`from core_lens.export import geoparquet`).
24
+
25
+ ## 1.5 Logging and Tracing
26
+
27
+ `core_lens` uses Loguru and is disabled by default. If you need to debug spatial resolution issues or trace validation errors, enable the internal logger:
28
+
29
+ ```python
30
+ import sys
31
+ from loguru import logger
32
+
33
+ logger.enable("core_lens")
34
+ logger.add(sys.stdout, level="DEBUG")
35
+ ```
36
+
37
+ ## 2. Initialization and Setup
38
+
39
+ Before querying, you MUST register the specific entities you plan to use.
40
+
41
+ ```python
42
+ from core_lens import AoI, SeasonConfig
43
+ from core_lens.entities import MWSEntity, TehsilEntity, DistrictEntity
44
+
45
+ # 1. Register entities FIRST
46
+ AoI.register(MWSEntity)
47
+ AoI.register(DistrictEntity)
48
+ # ... register others as needed
49
+
50
+ # 2. Scoping the AoI (Choose ONE mode)
51
+ DATA_ROOT = "data/"
52
+ # Mode A: By Administrative Hierarchy
53
+ aoi = AoI(DATA_ROOT, tehsil="Pangi", district="Chamba")
54
+ # Mode B: By Bounding Box
55
+ aoi = AoI(DATA_ROOT, bbox=(76.0, 31.0, 78.0, 33.0))
56
+ # Mode C: By specific Entity ID
57
+ aoi = AoI(DATA_ROOT, mws_id="13_551")
58
+
59
+ # Optional: Override default seasons (kharif, rabi, zaid)
60
+ custom_seasons = SeasonConfig(kharif=("06-01", "10-15"), rabi=("10-16", "02-28"), zaid=("03-01", "05-31"))
61
+ aoi_custom = AoI(DATA_ROOT, tehsil="Pangi", seasons=custom_seasons)
62
+ ```
63
+
64
+ ## 3. Querying & Filtering (Views)
65
+
66
+ Entities on the `AoI` object are accessed as **Views**. Views are lazy and can be chained.
67
+
68
+ ```python
69
+ # Access the view
70
+ mws_view = aoi.mws
71
+
72
+ # Attribute Filtering
73
+ filtered = mws_view.where(state="Himachal Pradesh")
74
+
75
+ # Spatial Filtering (inside the AoI)
76
+ spatial_filtered = mws_view.spatial_filter(bbox=(76.5, 31.5, 77.5, 32.5))
77
+
78
+ # Temporal Filtering
79
+ # Note: You can filter by exact years, a range of years, or predefined seasons.
80
+ from core_lens.base.view import Season
81
+ temporal_view = mws_view.between(year=(2020, 2022), season=Season.KHARIF)
82
+ current_view = mws_view.between(season=Season.CURRENT)
83
+ ```
84
+
85
+ ## 4. Materialization (Loading Data)
86
+
87
+ You must materialize the view into a `Result` to perform computations.
88
+ Choose the resolution based on what you need:
89
+ - `.static`: One row per entity, includes `geometry`.
90
+ - `.annual`: Time-series, aggregated annually. (NO geometry by default).
91
+ - `.sub_annual`: High-frequency time-series. (NO geometry by default).
92
+
93
+ ```python
94
+ static_result = filtered.static.materialise()
95
+ annual_result = temporal_view.annual.materialise()
96
+ ```
97
+ *Note: Depending on library version, you may only need `.static` instead of `.static.materialise()`. If `.static` returns a Result directly, use that.*
98
+
99
+ ```python
100
+ # To access underlying data structures:
101
+ df = static_result.df() # Polars DataFrame (Zero-copy)
102
+ lazy_df = static_result.lazy() # Polars LazyFrame
103
+ gdf = static_result.gdf() # GeoPandas GeoDataFrame (Heavy!)
104
+ ```
105
+
106
+ ## 5. Computation and Data Manipulation (Result API)
107
+
108
+ All data manipulation happens on the `Result` object.
109
+
110
+ ```python
111
+ import polars as pl
112
+
113
+ # Derive new columns
114
+ res = annual_result.derive("water_shortage", pl.col("rainfall") - pl.col("storage"))
115
+
116
+ # Aggregation (Supported 'by' keys: "year", "month", "season", "season_year")
117
+ agg_res = res.aggregate(pl.mean("ndvi").alias("avg_ndvi"), by="year")
118
+
119
+ # Reattaching Geometry
120
+ # Time-series results drop geometry to save memory. Reattach it if you need to map them:
121
+ geo_result = agg_res.with_geometry()
122
+ ```
123
+
124
+ ## 6. Spatial Joins
125
+
126
+ Spatial joins are lazy and enrich a primary entity with aggregated data from a secondary entity.
127
+
128
+ ```python
129
+ # Calculate the total forest tree_cover inside each MWS
130
+ joined_view = aoi.mws.spatial_join(
131
+ aoi.forest, agg={"tree_cover": "sum", "canopy_density": "mean"}
132
+ )
133
+ # Resulting columns are prefixed, e.g., 'forest_tree_cover', 'forest_canopy_density'
134
+ ```
135
+
136
+ ## 7. Statistical Analysis (`Result.stats`)
137
+
138
+ All stats methods are available under `result.stats` and return a NEW `Result`.
139
+ - Computed data is in `result.data` (which is a Polars DataFrame)
140
+ - Parameters and testing metrics are stored in `result.metadata`
141
+
142
+ ```python
143
+ # Descriptive stats
144
+ result.stats.describe(columns=["ndvi", "rainfall"])
145
+
146
+ # Correlation (pearson, spearman, kendall)
147
+ from core_lens.base.namespaces.stats import CorrelateMethod
148
+ result.stats.correlate(columns=["ndvi", "rainfall"], method=CorrelateMethod.PEARSON, across="entity")
149
+
150
+ # Hypothesis Testing (t-test, mann-whitney, wilcoxon, ks, chi-square)
151
+ from core_lens.base.namespaces.stats import TestMethod
152
+ result.stats.test(column="cropping_intensity", groups="temperature_zone", method=TestMethod.MANN_WHITNEY)
153
+
154
+ # Change Detection (absolute, percentage, trend)
155
+ from core_lens.base.namespaces.stats import ChangeMethod
156
+ result.stats.change(column="tree_cover", from_period=2018, to_period=2023, method=ChangeMethod.PERCENTAGE)
157
+
158
+ # Anomaly Detection
159
+ from core_lens.base.namespaces.stats import AnomalyTsMethod, AnomalyCrossMethod
160
+ # Mode 1: Cross-sectional (zscore, iqr, percentile, threshold)
161
+ result.stats.anomaly(column="ndvi", mode="cross_sectional", method=AnomalyCrossMethod.ZSCORE, baseline=(2010, 2020))
162
+ # Mode 2: Time-series (stl, cusum, mad)
163
+ result.stats.anomaly(column="ndvi", mode="timeseries", method=AnomalyTsMethod.STL, baseline=(2010, 2018))
164
+
165
+ # Similarity Matching (euclidean, cosine, mahalanobis, manhattan)
166
+ from core_lens.base.namespaces.stats import SimilarityMethod
167
+ result.stats.similarity(
168
+ target="13_551",
169
+ columns={"rainfall": ("annual", {"year": 2018})},
170
+ method=SimilarityMethod.EUCLIDEAN,
171
+ top_n=10
172
+ )
173
+ ```
174
+
175
+ ## 8. Plotting and Visualization (`Result.plot`)
176
+
177
+ Plots are explicitly accessed via the `result.plot` namespace. Maps use Lonboard (requires geometry); charts use Plotly. By default, charts render the top 10 entities.
178
+
179
+ ```python
180
+ # Map plotting (Requires result.with_geometry() to have been called for time-series)
181
+ result.plot.choropleth("ndvi", subplot_on="season_year")
182
+
183
+ # Charts
184
+ result.plot.timeseries(x="year", y="ndvi", top_n=10)
185
+ result.plot.scatter(x="rainfall", y="ndvi", top_n=10)
186
+ result.plot.distribution(x="ndvi", top_n=10)
187
+ result.plot.correlation(columns=["ndvi", "rainfall"], top_n=10)
188
+ result.plot.heatmap(x="year", y="month", value="ndvi", top_n=10)
189
+ result.plot.matrix()
190
+
191
+ # Plotting the AoI boundary itself
192
+ aoi.plot()
193
+ aoi.plot(overlay=result)
194
+ ```
195
+
196
+ ## 9. Exporting Results
197
+
198
+ Exporting uses standalone functions. Do not export massive datasets (like country-wide `mws`) to GeoJSON as it will bloat the file size. Use GeoParquet instead.
199
+
200
+ ```python
201
+ from core_lens.export import geoparquet, parquet, geojson
202
+
203
+ # For results with geometry
204
+ geoparquet(result, "output.geoparquet")
205
+
206
+ # For tabular results without geometry
207
+ parquet(result, "output.parquet")
208
+ ```
209
+
210
+ ## 10. Plugin System (Custom Entities)
211
+
212
+ You can extend core-lens with custom entities by subclassing `BaseEntity`.
213
+
214
+ ```python
215
+ from core_lens.base import BaseEntity
216
+
217
+ class CustomEntity(BaseEntity):
218
+ @property
219
+ def key_cols(self) -> list[str]:
220
+ return ["custom_id"]
221
+
222
+ @property
223
+ def geometry_col(self) -> str:
224
+ return "geometry"
225
+
226
+ @property
227
+ def static_path(self) -> str:
228
+ return "custom/static.geoparquet"
229
+
230
+ @property
231
+ def annual_path(self) -> str | None:
232
+ return "custom/annual.parquet"
233
+
234
+ AoI.register(CustomEntity)
235
+ ```
236
+
237
+ ## Agent Reminders & Pitfalls to Avoid
238
+ - **DO NOT** attempt to read parquet files directly using `pandas.read_parquet` or `polars.read_parquet`. Always construct an `AoI` and use the library's materialized `Result` output.
239
+ - **DO NOT** assume `annual` or `sub_annual` results contain geometry. You must call `.with_geometry()` to reattach it.
240
+ - **DO NOT** forget to import and register entities before initializing the `AoI`.
241
+ - **DO** use Polars expressions (`import polars as pl`) inside `.derive()` and `.aggregate()` methods for maximum performance.
242
+ - **DO** always read parameters/testing metrics from `result.metadata` and underlying stats from `result.data` after running any methods in `result.stats`.
@@ -32,12 +32,7 @@ from core_lens.export import geoparquet, geojson
32
32
  spatial_res = aoi.mws.annual.with_geometry()
33
33
 
34
34
  # Export to GeoParquet with DuckDB COPY options (e.g., partitioning)
35
- geoparquet(
36
- spatial_res,
37
- "output.geoparquet",
38
- compression="ZSTD",
39
- partition_by="year"
40
- )
35
+ geoparquet(spatial_res, "output.geoparquet", compression="ZSTD", partition_by="year")
41
36
 
42
37
  # Export to GeoJSON
43
38
  geojson(spatial_res, "output.json")
@@ -5,6 +5,7 @@ You can extend CoreLens by defining your own entities. Simply subclass `BaseEnti
5
5
  ```python
6
6
  from core_lens.base import BaseEntity
7
7
 
8
+
8
9
  class CustomEntity(BaseEntity):
9
10
  @property
10
11
  def key_cols(self) -> list[str]:
@@ -22,6 +23,7 @@ class CustomEntity(BaseEntity):
22
23
  def annual_path(self) -> str | None:
23
24
  return "custom/annual.parquet"
24
25
 
26
+
25
27
  # Register before use
26
28
  AoI.register(CustomEntity)
27
29
  ```
@@ -27,8 +27,7 @@ import polars as pl
27
27
  # 1. Derive new columns (works on any Result)
28
28
  res_annual = aoi.mws.annual
29
29
  derived_res = res_annual.derive(
30
- "drought_flag",
31
- pl.when(pl.col("rainfall") < 500).then(1).otherwise(0)
30
+ "drought_flag", pl.when(pl.col("rainfall") < 500).then(1).otherwise(0)
32
31
  )
33
32
 
34
33
  # 2. Aggregate (temporal grouping like "month" requires sub-annual, but "year" works on annual too)
@@ -47,11 +46,7 @@ Spatially join data from two different entities (e.g. Microwatersheds and Forest
47
46
 
48
47
  # Join forest metrics onto MWS geometries
49
48
  joined_view = aoi.mws.spatial_join(
50
- aoi.forest,
51
- agg={
52
- "tree_cover": "area",
53
- "canopy_density": "mean"
54
- }
49
+ aoi.forest, agg={"tree_cover": "area", "canopy_density": "mean"}
55
50
  )
56
51
 
57
52
  # Materialise the joined view
@@ -29,15 +29,11 @@ Create an `AoI` (Area of Interest) by specifying the `data_root` (which can be a
29
29
 
30
30
  ```python
31
31
  # Create an AoI scoped to a specific Tehsil using a local path
32
- aoi = AoI(
33
- data_root="/path/to/data",
34
- tehsil="Pangi"
35
- )
32
+ aoi = AoI(data_root="/path/to/data", tehsil="Pangi")
36
33
 
37
34
  # Or scope it directly to a specific list of entity IDs using an S3 bucket
38
35
  aoi_specific = AoI(
39
- data_root="s3://my-cloud-bucket/core-lens-data",
40
- mws_id=["13_001", "13_002"]
36
+ data_root="s3://my-cloud-bucket/core-lens-data", mws_id=["13_001", "13_002"]
41
37
  )
42
38
 
43
39
  # You can now access entities scoped to this AoI:
@@ -21,8 +21,8 @@ from core_lens.base.namespaces.stats import CorrelateMethod
21
21
 
22
22
  corr = res.stats.correlate(
23
23
  columns=["ndvi", "rainfall", "temperature"],
24
- method=CorrelateMethod.PEARSON, # or SPEARMAN, KENDALL
25
- across="entity" # correlate across entities or time
24
+ method=CorrelateMethod.PEARSON, # or SPEARMAN, KENDALL
25
+ across="entity", # correlate across entities or time
26
26
  )
27
27
  ```
28
28
 
@@ -37,14 +37,12 @@ from core_lens.base.namespaces.stats import TestMethod
37
37
  test_res = res.stats.test(
38
38
  column="cropping_intensity",
39
39
  groups="temperature_zone",
40
- method=TestMethod.MANN_WHITNEY
40
+ method=TestMethod.MANN_WHITNEY,
41
41
  )
42
42
 
43
43
  # Period-based testing
44
44
  test_period = res.stats.test(
45
- column="ndvi",
46
- periods=[(2010, 2015), (2016, 2023)],
47
- method=TestMethod.T_TEST
45
+ column="ndvi", periods=[(2010, 2015), (2016, 2023)], method=TestMethod.T_TEST
48
46
  )
49
47
  ```
50
48
 
@@ -57,10 +55,7 @@ from core_lens.base.namespaces.stats import ChangeMethod
57
55
 
58
56
  # Trend over time
59
57
  trend = res.stats.change(
60
- column="ndvi",
61
- from_period=2010,
62
- to_period=2023,
63
- method=ChangeMethod.TREND
58
+ column="ndvi", from_period=2010, to_period=2023, method=ChangeMethod.TREND
64
59
  )
65
60
 
66
61
  # Absolute or percentage change
@@ -68,7 +63,7 @@ pct_change = res.stats.change(
68
63
  column="tree_cover",
69
64
  from_period=2018,
70
65
  to_period=2023,
71
- method=ChangeMethod.PERCENTAGE
66
+ method=ChangeMethod.PERCENTAGE,
72
67
  )
73
68
  ```
74
69
 
@@ -81,10 +76,7 @@ from core_lens.base.namespaces.stats import AnomalyTsMethod, AnomalyCrossMethod
81
76
 
82
77
  # Timeseries anomaly against its own history
83
78
  ts_anomalies = res.stats.anomaly(
84
- column="ndvi",
85
- mode="timeseries",
86
- method=AnomalyTsMethod.STL,
87
- baseline=(2010, 2018)
79
+ column="ndvi", mode="timeseries", method=AnomalyTsMethod.STL, baseline=(2010, 2018)
88
80
  )
89
81
 
90
82
  # Cross-sectional anomaly against other entities
@@ -92,7 +84,7 @@ cross_anomalies = res.stats.anomaly(
92
84
  column="ndvi",
93
85
  mode="cross_sectional",
94
86
  method=AnomalyCrossMethod.ZSCORE,
95
- baseline=(2010, 2020)
87
+ baseline=(2010, 2020),
96
88
  )
97
89
  ```
98
90
 
@@ -107,9 +99,9 @@ similar = res.stats.similarity(
107
99
  target="13_551",
108
100
  columns={
109
101
  "rainfall": ("annual", {"year": 2018}),
110
- "ndvi": ("sub_annual", {"season": "kharif", "year": 2020})
102
+ "ndvi": ("sub_annual", {"season": "kharif", "year": 2020}),
111
103
  },
112
104
  method=SimilarityMethod.EUCLIDEAN,
113
- top_n=10
105
+ top_n=10,
114
106
  )
115
107
  ```
@@ -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.dev180'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev180')
21
+ __version__ = version = '0.1.dev184'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev184')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -20,7 +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
+ from core_lens.utils.polars_utils import cached_read_schema, parquet_scan_path
24
24
 
25
25
  if TYPE_CHECKING:
26
26
  from core_lens.base.view import View
@@ -225,7 +225,9 @@ def _cached_resolve_boundary(
225
225
  geom_col = schema.geometry_col
226
226
  static_path = candidate._resolve(candidate.static_path)
227
227
 
228
- lf = pl.scan_parquet(static_path, storage_options=storage_options or None)
228
+ lf = pl.scan_parquet(
229
+ parquet_scan_path(static_path), storage_options=storage_options or None
230
+ )
229
231
  filter_expr = pl.lit(True)
230
232
  schema_types = cached_read_schema(static_path, storage_options)
231
233
  for col, val in entity_kwargs.items():
@@ -29,7 +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
+ from core_lens.utils.polars_utils import cached_read_schema, parquet_scan_path
33
33
 
34
34
  if TYPE_CHECKING:
35
35
  import shapely
@@ -475,7 +475,7 @@ class BaseEntity(ABC):
475
475
  )
476
476
 
477
477
  # --- Combine attribute and spatial filters into a single lazy pipeline ---
478
- lf = pl.scan_parquet(static)
478
+ lf = pl.scan_parquet(parquet_scan_path(static))
479
479
 
480
480
  if attr_kwargs:
481
481
  filter_expr = pl.lit(True)
@@ -102,10 +102,12 @@ def detect(
102
102
  if lon_col:
103
103
  reserved_static.add(lon_col)
104
104
  extra_static_cols = [c for c in static_schema if c not in reserved_static]
105
+ col_types_static = {col: str(dtype) for col, dtype in static_schema.items()}
105
106
 
106
107
  annual_time_col: str | None = None
107
108
  extra_annual_cols: list[str] = []
108
109
  annual_is_year_col: bool | None = None
110
+ col_types_annual: dict[str, str] = {}
109
111
  if annual_path:
110
112
  annual_schema = _read_schema(
111
113
  annual_path, label="annual", storage_options=_so or None
@@ -116,10 +118,12 @@ def detect(
116
118
  )
117
119
  extra_annual_cols = [c for c in annual_schema if c not in reserved_annual]
118
120
  annual_is_year_col = _is_year_col_from_schema(annual_schema, annual_time_col)
121
+ col_types_annual = {col: str(dtype) for col, dtype in annual_schema.items()}
119
122
 
120
123
  sub_annual_time_col: str | None = None
121
124
  extra_sub_annual_cols: list[str] = []
122
125
  sub_annual_is_year_col: bool | None = None
126
+ col_types_sub_annual: dict[str, str] = {}
123
127
  if sub_annual_path:
124
128
  sub_annual_schema = _read_schema(
125
129
  sub_annual_path, label="sub_annual", storage_options=_so or None
@@ -132,6 +136,9 @@ def detect(
132
136
  sub_annual_is_year_col = _is_year_col_from_schema(
133
137
  sub_annual_schema, sub_annual_time_col
134
138
  )
139
+ col_types_sub_annual = {
140
+ col: str(dtype) for col, dtype in sub_annual_schema.items()
141
+ }
135
142
 
136
143
  return SchemaProfile(
137
144
  key_cols=key_cols,
@@ -143,6 +150,9 @@ def detect(
143
150
  extra_static_cols=extra_static_cols,
144
151
  extra_annual_cols=extra_annual_cols,
145
152
  extra_sub_annual_cols=extra_sub_annual_cols,
153
+ col_types_static=col_types_static,
154
+ col_types_annual=col_types_annual,
155
+ col_types_sub_annual=col_types_sub_annual,
146
156
  annual_is_year_col=annual_is_year_col,
147
157
  sub_annual_is_year_col=sub_annual_is_year_col,
148
158
  )
@@ -153,10 +163,18 @@ def _read_schema(
153
163
  label: str,
154
164
  storage_options: dict[str, Any] | None = None,
155
165
  ) -> pl.Schema:
166
+ import os
167
+
156
168
  _so = storage_options or {}
169
+ # When path is a directory, use a glob to read only .parquet files.
170
+ # This prevents polars from raising InvalidOperationError when the directory
171
+ # contains mixed-extension files (e.g. data_dictionary.csv alongside .parquet).
172
+ scan_path = path
173
+ if os.path.isdir(path):
174
+ scan_path = os.path.join(path, "**", "*.parquet")
157
175
  try:
158
176
  return pl.scan_parquet(
159
- path,
177
+ scan_path,
160
178
  hive_partitioning=True,
161
179
  storage_options=_so or None,
162
180
  ).collect_schema()
@@ -56,6 +56,12 @@ class SchemaProfile(BaseModel):
56
56
  is used for validation and documentation only.
57
57
  extra_annual_cols: Additional attribute columns in the annual file.
58
58
  extra_sub_annual_cols: Additional attribute columns in the sub_annual file.
59
+ col_types_static: Mapping of **all** column names in the static file to
60
+ their Polars dtype string (e.g. ``{"basin_id": "Int32", "geometry": "Binary"}``).
61
+ Populated by the auto-detection layer; ``{}`` when the profile is
62
+ constructed manually.
63
+ col_types_annual: Same as ``col_types_static`` but for the annual file.
64
+ col_types_sub_annual: Same as ``col_types_static`` but for the sub_annual file.
59
65
 
60
66
  """
61
67
 
@@ -70,6 +76,11 @@ class SchemaProfile(BaseModel):
70
76
  extra_static_cols: list[str] = []
71
77
  extra_annual_cols: list[str] = []
72
78
  extra_sub_annual_cols: list[str] = []
79
+ # Column name → Polars dtype string for every column in each resolution.
80
+ # Populated by the auto-detection layer; empty dict when built manually.
81
+ col_types_static: dict[str, str] = {}
82
+ col_types_annual: dict[str, str] = {}
83
+ col_types_sub_annual: dict[str, str] = {}
73
84
  # Whether the time column is an integer-year column (True), a Date/Datetime
74
85
  # column (False), or absent/unknown (None). Determined at detect() time from
75
86
  # the Parquet schema — eliminates a redundant collect_schema() call in
@@ -7,6 +7,8 @@ import functools
7
7
 
8
8
  import polars as pl
9
9
 
10
+ import os
11
+
10
12
  _GPU_AVAILABLE: bool | None = None # None = not yet probed
11
13
 
12
14
 
@@ -16,9 +18,34 @@ def _so_key(storage_options: dict[str, Any] | None) -> tuple[tuple[str, Any], ..
16
18
  return tuple(sorted(storage_options.items()))
17
19
 
18
20
 
21
+ def parquet_scan_path(path: str) -> str:
22
+ """Return a glob-safe path for ``pl.scan_parquet``.
23
+
24
+ When *path* is a directory polars will reject it if the directory contains
25
+ files with mixed extensions (e.g. a ``data_dictionary.csv`` sitting
26
+ alongside Hive-partitioned ``.parquet`` files). This helper coerces a bare
27
+ directory to ``<path>/**/*.parquet`` so only Parquet files are matched.
28
+
29
+ Args:
30
+ path (str): Filesystem path or cloud URI to a Parquet file or directory.
31
+
32
+ Returns:
33
+ str: The original path if it already points to a file, otherwise
34
+ ``<path>/**/*.parquet``.
35
+ """
36
+ if os.path.isdir(path):
37
+ return os.path.join(path, "**", "*.parquet")
38
+ return path
39
+
40
+
19
41
  @functools.cache
20
42
  def _cached_schema_internal(path: str, so_key: tuple[tuple[str, Any], ...]) -> Any:
21
- return pl.scan_parquet(path, storage_options=dict(so_key) or None).collect_schema()
43
+ # Coerce bare directory paths to a *.parquet glob so polars doesn't raise
44
+ # InvalidOperationError when the directory contains mixed-extension files
45
+ # (e.g. data_dictionary.csv alongside Hive-partitioned .parquet files).
46
+ return pl.scan_parquet(
47
+ parquet_scan_path(path), storage_options=dict(so_key) or None
48
+ ).collect_schema()
22
49
 
23
50
 
24
51
  def cached_read_schema(path: str, storage_options: dict[str, Any] | None = None) -> Any:
@@ -12,6 +12,7 @@ import shapely
12
12
  from loguru import logger
13
13
 
14
14
  from core_lens.utils.paths import is_cloud_uri, path_exists, resolve_fs_and_path
15
+ from core_lens.utils.polars_utils import parquet_scan_path
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  pass
@@ -361,7 +362,7 @@ def exact_spatial_filter(
361
362
 
362
363
  # Build a lazy scan and push down an inner join to load only required geometries.
363
364
  full_df = (
364
- pl.scan_parquet(static_path)
365
+ pl.scan_parquet(parquet_scan_path(static_path))
365
366
  .select(key_cols + [geometry_col])
366
367
  .join(candidates.select(key_cols).lazy(), on=key_cols, how="semi")
367
368
  .collect()
@@ -684,7 +685,7 @@ def point_in_entities(
684
685
  static_path: str = entity._resolve(entity.static_path)
685
686
 
686
687
  geom_df = (
687
- pl.scan_parquet(static_path)
688
+ pl.scan_parquet(parquet_scan_path(static_path))
688
689
  .select(key_cols + [geom_col])
689
690
  .join(candidates.select(key_cols).lazy(), on=key_cols, how="semi")
690
691
  .collect()