core-lens 0.1.dev114__tar.gz → 0.1.dev120__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 (78) hide show
  1. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/PKG-INFO +8 -1
  2. core_lens-0.1.dev120/docs/source/export.md +47 -0
  3. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/index.rst +2 -0
  4. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/intro.md +2 -1
  5. core_lens-0.1.dev120/docs/source/logging.md +32 -0
  6. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/plots.md +1 -1
  7. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/pyproject.toml +6 -1
  8. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/__init__.py +4 -0
  9. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/_version.py +2 -2
  10. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/aoi.py +66 -16
  11. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/entity.py +54 -18
  12. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/namespaces/plot.py +25 -25
  13. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/namespaces/stats.py +25 -25
  14. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/result.py +16 -10
  15. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/view.py +52 -18
  16. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/entities/mws.py +15 -0
  17. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/entities/tehsil.py +12 -0
  18. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/entities/waterbody.py +12 -0
  19. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/export/formats.py +15 -15
  20. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/schema/detection.py +6 -6
  21. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/schema/profile.py +2 -2
  22. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/utils/polars_utils.py +10 -7
  23. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/utils/season.py +52 -22
  24. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/utils/spatial.py +58 -28
  25. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_entity.py +73 -0
  26. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_plot.py +35 -0
  27. core_lens-0.1.dev120/tests/unit/test_polars_utils.py +87 -0
  28. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_spatial.py +104 -0
  29. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_stats.py +219 -11
  30. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_view.py +76 -0
  31. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/uv.lock +251 -251
  32. core_lens-0.1.dev114/tests/unit/test_polars_utils.py +0 -14
  33. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.github/pull_request_template.md +0 -0
  34. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.github/workflows/ci.yml +0 -0
  35. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.github/workflows/gh-pages.yml +0 -0
  36. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.github/workflows/pre-release.yml +0 -0
  37. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.github/workflows/release.yml +0 -0
  38. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.gitignore +0 -0
  39. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.gitmessage +0 -0
  40. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.pre-commit-config.yaml +0 -0
  41. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/.python-version +0 -0
  42. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/CONTRIBUTING.md +0 -0
  43. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/LICENSE +0 -0
  44. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/README.md +0 -0
  45. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/SKILLS.md +0 -0
  46. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/Makefile +0 -0
  47. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/make.bat +0 -0
  48. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/concepts.md +0 -0
  49. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/conf.py +0 -0
  50. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/plugins.md +0 -0
  51. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/queries.md +0 -0
  52. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/quickstart.md +0 -0
  53. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/docs/source/stats.md +0 -0
  54. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/examples/demo_mws.py +0 -0
  55. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/examples/demo_tehsil.py +0 -0
  56. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/hooks/mypy.sh +0 -0
  57. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/hooks/no-parquet-outside-fixtures.sh +0 -0
  58. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/hooks/pytest.sh +0 -0
  59. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/__main__.py +0 -0
  60. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/__init__.py +0 -0
  61. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/base/namespaces/__init__.py +0 -0
  62. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/entities/__init__.py +0 -0
  63. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/export/__init__.py +0 -0
  64. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/py.typed +0 -0
  65. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/schema/__init__.py +0 -0
  66. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/src/core_lens/utils/__init__.py +0 -0
  67. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/fixtures/generate_fixtures.py +0 -0
  68. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/conftest.py +0 -0
  69. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_aoi.py +0 -0
  70. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_entities.py +0 -0
  71. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_export.py +0 -0
  72. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_main.py +0 -0
  73. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_profile.py +0 -0
  74. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_result.py +0 -0
  75. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_schema_detection.py +0 -0
  76. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_schema_profile.py +0 -0
  77. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_season.py +0 -0
  78. {core_lens-0.1.dev114 → core_lens-0.1.dev120}/tests/unit/test_season_config.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-lens
3
- Version: 0.1.dev114
3
+ Version: 0.1.dev120
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
@@ -17,6 +17,7 @@ Requires-Python: >=3.13
17
17
  Requires-Dist: core-lens[core]
18
18
  Requires-Dist: core-lens[spatial]
19
19
  Provides-Extra: core
20
+ Requires-Dist: loguru>=0.7.3; extra == 'core'
20
21
  Requires-Dist: polars<2,>=1.39.0; extra == 'core'
21
22
  Requires-Dist: pyarrow<25,>=23.0.0; extra == 'core'
22
23
  Requires-Dist: pydantic<3,>=2.13.3; extra == 'core'
@@ -25,6 +26,7 @@ Requires-Dist: bokeh<4,>=3.6.0; extra == 'full'
25
26
  Requires-Dist: duckdb<2,>=1.5.3; extra == 'full'
26
27
  Requires-Dist: geoarrow-rust-core>=0.3.0; extra == 'full'
27
28
  Requires-Dist: geopandas<2,>=1.1.3; extra == 'full'
29
+ Requires-Dist: loguru>=0.7.3; extra == 'full'
28
30
  Requires-Dist: lonboard<1,>=0.16.0; extra == 'full'
29
31
  Requires-Dist: matplotlib<4,>=3.10.9; extra == 'full'
30
32
  Requires-Dist: polars<2,>=1.39.0; extra == 'full'
@@ -41,6 +43,7 @@ Requires-Dist: cudf-polars-cu13==26.6.*; extra == 'full-gpu'
41
43
  Requires-Dist: duckdb<2,>=1.5.3; extra == 'full-gpu'
42
44
  Requires-Dist: geoarrow-rust-core>=0.3.0; extra == 'full-gpu'
43
45
  Requires-Dist: geopandas<2,>=1.1.3; extra == 'full-gpu'
46
+ Requires-Dist: loguru>=0.7.3; extra == 'full-gpu'
44
47
  Requires-Dist: lonboard<1,>=0.16.0; extra == 'full-gpu'
45
48
  Requires-Dist: matplotlib<4,>=3.10.9; extra == 'full-gpu'
46
49
  Requires-Dist: polars<2,>=1.39.0; extra == 'full-gpu'
@@ -53,18 +56,21 @@ Requires-Dist: statsmodels<1,>=0.14.6; extra == 'full-gpu'
53
56
  Provides-Extra: gpu
54
57
  Requires-Dist: cudf-cu13==26.6.*; extra == 'gpu'
55
58
  Requires-Dist: cudf-polars-cu13==26.6.*; extra == 'gpu'
59
+ Requires-Dist: loguru>=0.7.3; extra == 'gpu'
56
60
  Requires-Dist: polars<2,>=1.39.0; extra == 'gpu'
57
61
  Requires-Dist: pyarrow<25,>=23.0.0; extra == 'gpu'
58
62
  Requires-Dist: pydantic<3,>=2.13.3; extra == 'gpu'
59
63
  Provides-Extra: spatial
60
64
  Requires-Dist: duckdb<2,>=1.5.3; extra == 'spatial'
61
65
  Requires-Dist: geopandas<2,>=1.1.3; extra == 'spatial'
66
+ Requires-Dist: loguru>=0.7.3; extra == 'spatial'
62
67
  Requires-Dist: polars<2,>=1.39.0; extra == 'spatial'
63
68
  Requires-Dist: pyarrow<25,>=23.0.0; extra == 'spatial'
64
69
  Requires-Dist: pydantic<3,>=2.13.3; extra == 'spatial'
65
70
  Requires-Dist: pyproj<4,>=3.7.2; extra == 'spatial'
66
71
  Requires-Dist: shapely<3,>=2.1.2; extra == 'spatial'
67
72
  Provides-Extra: stats
73
+ Requires-Dist: loguru>=0.7.3; extra == 'stats'
68
74
  Requires-Dist: polars<2,>=1.39.0; extra == 'stats'
69
75
  Requires-Dist: pyarrow<25,>=23.0.0; extra == 'stats'
70
76
  Requires-Dist: pydantic<3,>=2.13.3; extra == 'stats'
@@ -73,6 +79,7 @@ Requires-Dist: statsmodels<1,>=0.14.6; extra == 'stats'
73
79
  Provides-Extra: viz
74
80
  Requires-Dist: bokeh<4,>=3.6.0; extra == 'viz'
75
81
  Requires-Dist: geoarrow-rust-core>=0.3.0; extra == 'viz'
82
+ Requires-Dist: loguru>=0.7.3; extra == 'viz'
76
83
  Requires-Dist: lonboard<1,>=0.16.0; extra == 'viz'
77
84
  Requires-Dist: matplotlib<4,>=3.10.9; extra == 'viz'
78
85
  Requires-Dist: polars<2,>=1.39.0; extra == 'viz'
@@ -0,0 +1,47 @@
1
+ # Data Export
2
+
3
+ CoreLens provides a unified `core_lens.export` module for saving `Result` objects to various file formats. Standard tabular formats use Polars for high-speed writes, while geospatial formats leverage DuckDB's spatial extensions for robust geometry encoding.
4
+
5
+ ## Standard Formats
6
+
7
+ Export non-spatial results to standard formats. Note that these formats cannot encode geometry columns; if your `Result` has geometry, these functions will raise a `TypeError`.
8
+
9
+ ```python
10
+ from core_lens.export import parquet, csv, json
11
+
12
+ res = aoi.mws.annual
13
+
14
+ # Export to Parquet with ZSTD compression
15
+ parquet(res, "output.parquet", compression="zstd")
16
+
17
+ # Export to CSV
18
+ csv(res, "output.csv", separator=",")
19
+
20
+ # Export to JSON
21
+ json(res, "output.json", pretty=True)
22
+ ```
23
+
24
+ ## Geospatial Formats
25
+
26
+ Export spatial data to GeoParquet and GeoJSON. The result must have geometry attached (e.g., by calling `.with_geometry()`).
27
+
28
+ ```python
29
+ from core_lens.export import geoparquet, geojson
30
+
31
+ # Make sure geometry is attached!
32
+ spatial_res = aoi.mws.annual.with_geometry()
33
+
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
+ )
41
+
42
+ # Export to GeoJSON
43
+ geojson(spatial_res, "output.json")
44
+ ```
45
+
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.
@@ -21,5 +21,7 @@ Provides a unified interface over microwatershed hydrology, administrative bound
21
21
  queries
22
22
  stats
23
23
  plots
24
+ export
24
25
  plugins
26
+ logging
25
27
  autoapi/index
@@ -12,4 +12,5 @@ CoreLens provides a unified interface over microwatersheds, administrative bound
12
12
  - **Pluggable Entities**: Built-in support for standard units (MWS, Tehsil) with a simple plugin architecture for adding new domain entities.
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
- - **Interactive Visualisation**: Generate interactive maps using Lonboard and timeseries/scatter plots using Bokeh directly from results.
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.
@@ -0,0 +1,32 @@
1
+ # Logging
2
+
3
+ `core_lens` uses [Loguru](https://loguru.readthedocs.io/) for internal logging and tracing.
4
+
5
+ To avoid polluting the standard output of your downstream applications, the logger is disabled by default upon import.
6
+
7
+ ## Enabling Logs
8
+
9
+ If you want to view the internal tracing logs (useful for debugging spatial resolution issues, inspecting generated time filters, or tracing validation errors), you need to explicitly enable the `core_lens` logger in your application and add a handler (e.g., `sys.stdout` or a log file).
10
+
11
+ ```python
12
+ import sys
13
+ from loguru import logger
14
+ from core_lens import AoI
15
+
16
+ # 1. Enable the internal logger
17
+ logger.enable("core_lens")
18
+
19
+ # 2. Add a handler with your desired level and formatting
20
+ logger.add(sys.stdout, level="DEBUG")
21
+
22
+ # 3. Operations will now emit tracing logs
23
+ aoi = AoI("data/", tehsil="Pangi", district="Chamba")
24
+ ```
25
+
26
+ ## Log Levels
27
+
28
+ `core_lens` emits logs at different levels depending on the operational context:
29
+
30
+ - **INFO**: Materialisation events (reading static, annual, or fortnightly Parquet files), entity registrations, AoI initialisation, and cross-entity spatial joins.
31
+ - **DEBUG**: Sub-operations such as spatial bounding box index generation, exact geometry relationships, lazy attribute filters, and query generation.
32
+ - **ERROR**: Incorrect initialisation arguments, failed path validations, missing geometries, unknown schema structures, or conflicting date configurations.
@@ -1,6 +1,6 @@
1
1
  # Visualisation and Plots
2
2
 
3
- The `result.plot` namespace provides integrated plotting capabilities. Spatial plots use **Lonboard** for high-performance interactive maps, while charts and timeseries use **Bokeh**.
3
+ The `result.plot` namespace provides integrated plotting capabilities. Spatial plots use **Lonboard** for high-performance interactive maps (achieved via zero-copy GeoArrow memory mapping directly from Polars, bypassing GeoPandas), while charts and timeseries use **Bokeh**.
4
4
 
5
5
  Plot methods return plot objects (like a Bokeh Figure or Lonboard Map) which can be displayed using `bokeh.io.show` or exported via `bokeh.io.save`.
6
6
 
@@ -44,7 +44,12 @@ venvPath = "."
44
44
  venv = ".venv"
45
45
 
46
46
  [project.optional-dependencies]
47
- core = ["polars>=1.39.0,<2", "pyarrow>=23.0.0,<25", "pydantic>=2.13.3,<3"]
47
+ core = [
48
+ "polars>=1.39.0,<2",
49
+ "pyarrow>=23.0.0,<25",
50
+ "pydantic>=2.13.3,<3",
51
+ "loguru>=0.7.3",
52
+ ]
48
53
  spatial = [
49
54
  "core-lens[core]",
50
55
  "duckdb>=1.5.3,<2",
@@ -6,4 +6,8 @@ except ImportError:
6
6
  from core_lens.aoi import AoI, SeasonConfig
7
7
  from core_lens.entities.tehsil import TehsilEntity
8
8
 
9
+ from loguru import logger
10
+
11
+ logger.disable("core_lens")
12
+
9
13
  __all__ = ["__version__", "AoI", "SeasonConfig", "TehsilEntity"]
@@ -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.dev114'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev114')
21
+ __version__ = version = '0.1.dev120'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev120')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
12
12
  from core_lens.base.result import Result
13
13
 
14
14
  import polars as pl
15
+ from loguru import logger
15
16
 
16
17
  from core_lens.base.entity import BaseEntity, EntityValidationError
17
18
 
@@ -67,10 +68,10 @@ class SeasonConfig:
67
68
  """Return the season name for a given date.
68
69
 
69
70
  Args:
70
- d: The date to classify.
71
+ d (date): The date to classify.
71
72
 
72
73
  Returns:
73
- ``"kharif"``, ``"rabi"``, or ``"zaid"``.
74
+ str: ``"kharif"``, ``"rabi"``, or ``"zaid"``.
74
75
  """
75
76
  md = f"{d.month:02d}-{d.day:02d}"
76
77
  for name in ("kharif", "rabi", "zaid"):
@@ -155,16 +156,16 @@ class AoI:
155
156
  """Resolve the AoI boundary and scope all registered entities.
156
157
 
157
158
  Args:
158
- data_root: Path to the root data directory. Prepended to every
159
+ data_root (str): Path to the root data directory. Prepended to every
159
160
  relative entity path that does not start with ``/`` or a URI
160
161
  scheme.
161
- bbox: Bounding box as ``(minx, miny, maxx, maxy)`` in WGS-84.
162
+ bbox (tuple[float, float, float, float] | None, optional): Bounding box as ``(minx, miny, maxx, maxy)`` in WGS-84.
162
163
  Mutually exclusive with ``geometry`` and ``entity_kwargs``.
163
- geometry: Arbitrary Shapely geometry. Used as-is. Mutually
164
+ geometry (shapely.Geometry | None, optional): Arbitrary Shapely geometry. Used as-is. Mutually
164
165
  exclusive with ``bbox`` and ``entity_kwargs``.
165
- seasons: :class:`SeasonConfig` override. Defaults to the library
166
+ seasons (SeasonConfig | None, optional): :class:`SeasonConfig` override. Defaults to the library
166
167
  agronomic defaults.
167
- **entity_kwargs: Named filter pairs that identify the boundary,
168
+ **entity_kwargs (str | list[str]): Named filter pairs that identify the boundary,
168
169
  e.g. ``tehsil="Pangi"``, ``district="Chamba"``,
169
170
  ``state="Himachal Pradesh"``, ``mws_id="13_551"``.
170
171
  Mutually exclusive with ``bbox`` and ``geometry``.
@@ -177,6 +178,7 @@ class AoI:
177
178
  """
178
179
  self.data_root = pathlib.Path(data_root).resolve()
179
180
  self.seasons: SeasonConfig = seasons or SeasonConfig()
181
+ logger.info("Initializing AoI with data_root={}", self.data_root)
180
182
 
181
183
  n_modes = sum(
182
184
  [
@@ -186,11 +188,13 @@ class AoI:
186
188
  ]
187
189
  )
188
190
  if n_modes == 0:
191
+ logger.error("AoI initialization failed: no boundary argument supplied.")
189
192
  raise ValueError(
190
193
  "AoI requires exactly one boundary argument: "
191
194
  "bbox=, geometry=, or entity keyword filters such as tehsil='Pangi'."
192
195
  )
193
196
  if n_modes > 1:
197
+ logger.error("AoI initialization failed: multiple boundary modes supplied.")
194
198
  raise ValueError(
195
199
  "bbox, geometry, and entity keyword filters are mutually exclusive. "
196
200
  "Provide exactly one boundary mode."
@@ -219,7 +223,7 @@ class AoI:
219
223
  """The season name for today's date under the AoI's SeasonConfig.
220
224
 
221
225
  Returns:
222
- ``"kharif"``, ``"rabi"``, or ``"zaid"``.
226
+ str: ``"kharif"``, ``"rabi"``, or ``"zaid"``.
223
227
  """
224
228
  return self.seasons.season_for(date.today())
225
229
 
@@ -228,7 +232,7 @@ class AoI:
228
232
  """The current calendar year.
229
233
 
230
234
  Returns:
231
- Current year as an integer.
235
+ int: Current year as an integer.
232
236
  """
233
237
  return date.today().year
234
238
 
@@ -236,11 +240,11 @@ class AoI:
236
240
  """Render an interactive Lonboard map of the AoI and its entity layers.
237
241
 
238
242
  Args:
239
- overlay: An optional :class:`~core_lens.base.result.Result` to
243
+ overlay (Result | None, optional): An optional :class:`~core_lens.base.result.Result` to
240
244
  overlay on the map.
241
245
 
242
246
  Returns:
243
- A Lonboard Map object.
247
+ Any: A Lonboard Map object.
244
248
  """
245
249
  import lonboard
246
250
  import geopandas as gpd
@@ -309,10 +313,10 @@ class AoI:
309
313
  the unique matching row).
310
314
 
311
315
  Args:
312
- entity_kwargs: Column–value pairs used to identify the boundary.
316
+ entity_kwargs (dict[str, str | list[str]]): Column–value pairs used to identify the boundary.
313
317
 
314
318
  Returns:
315
- The union of all matching entity geometries as a Shapely object.
319
+ shapely.Geometry: The union of all matching entity geometries as a Shapely object.
316
320
 
317
321
  Raises:
318
322
  :class:`~core_lens.base.EntityValidationError`: If no registered entity can satisfy the filters.
@@ -320,6 +324,8 @@ class AoI:
320
324
  """
321
325
  import shapely.ops as sops
322
326
 
327
+ logger.debug("Resolving named boundary using kwargs: {}", entity_kwargs)
328
+
323
329
  # Find the registered entity whose key_col or known attribute column
324
330
  # matches one of the filter keys.
325
331
  candidate: BaseEntity | None = None
@@ -335,12 +341,19 @@ class AoI:
335
341
  # Fall back: look for an entity whose name matches a kwarg key
336
342
  # (e.g. tehsil="Pangi" → TehsilEntity if registered as "tehsil").
337
343
  if candidate is None:
344
+ logger.debug(
345
+ "No direct column match found for kwargs, attempting entity name fallback"
346
+ )
338
347
  for name, entity in self._entity_instances.items():
339
348
  if name in entity_kwargs:
340
349
  candidate = entity
341
350
  break
342
351
 
343
352
  if candidate is None:
353
+ logger.error(
354
+ "Boundary resolution failed: no registered entity matched filters {}",
355
+ entity_kwargs,
356
+ )
344
357
  raise EntityValidationError(
345
358
  f"No registered entity can satisfy the filters {entity_kwargs}. "
346
359
  f"Registered entities: {sorted(_REGISTRY)}."
@@ -367,6 +380,9 @@ class AoI:
367
380
  df = lf.filter(filter_expr).select(candidate.key_cols + [geom_col]).collect()
368
381
 
369
382
  if df.is_empty():
383
+ logger.error(
384
+ "Boundary resolution failed: no rows matched filters {}", entity_kwargs
385
+ )
370
386
  raise ValueError(
371
387
  f"No rows matched the filters {entity_kwargs} "
372
388
  f"in {candidate.static_path!r}."
@@ -391,7 +407,7 @@ class AoI:
391
407
  :class:`AoI` is instantiated (when ``data_root`` is known).
392
408
 
393
409
  Args:
394
- entity_cls: A concrete subclass of
410
+ entity_cls (type[BaseEntity]): A concrete subclass of
395
411
  :class:`~core_lens.base.entity.BaseEntity`.
396
412
 
397
413
  Raises:
@@ -401,6 +417,7 @@ class AoI:
401
417
  import pathlib as _pathlib
402
418
 
403
419
  name = _entity_name(entity_cls)
420
+ logger.info("Registering entity class: {}", name)
404
421
  # Probe with a no-root instance to check if paths are absolute.
405
422
  probe = entity_cls()
406
423
  static = probe.static_path
@@ -417,9 +434,10 @@ class AoI:
417
434
  Primarily useful in tests where a clean registry is needed between runs.
418
435
 
419
436
  Args:
420
- entity_cls: The entity class to remove.
437
+ entity_cls (type[BaseEntity]): The entity class to remove.
421
438
  """
422
439
  name = _entity_name(entity_cls)
440
+ logger.info("Deregistering entity class: {}", name)
423
441
  _REGISTRY.pop(name, None)
424
442
 
425
443
  @classmethod
@@ -427,7 +445,7 @@ class AoI:
427
445
  """Return the names of all currently registered entities.
428
446
 
429
447
  Returns:
430
- A sorted list of entity name strings.
448
+ list[str]: A sorted list of entity name strings.
431
449
  """
432
450
  return sorted(_REGISTRY)
433
451
 
@@ -452,11 +470,21 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
452
470
  try:
453
471
  static = entity._resolve(entity.static_path)
454
472
  except FileNotFoundError:
473
+ logger.error(
474
+ "Validation failed for entity {}: static_path '{}' does not exist.",
475
+ name,
476
+ entity.static_path,
477
+ )
455
478
  raise EntityValidationError(
456
479
  f"Entity {name!r}: static_path {entity.static_path!r} does not exist."
457
480
  )
458
481
 
459
482
  if not os.path.exists(static):
483
+ logger.error(
484
+ "Validation failed for entity {}: static path '{}' does not exist.",
485
+ name,
486
+ static,
487
+ )
460
488
  raise EntityValidationError(
461
489
  f"Entity {name!r}: static_path {static!r} does not exist."
462
490
  )
@@ -464,18 +492,34 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
464
492
  try:
465
493
  schema = pl.read_parquet_schema(static)
466
494
  except Exception as exc:
495
+ logger.error(
496
+ "Validation failed for entity {}: could not read schema from '{}': {}",
497
+ name,
498
+ static,
499
+ exc,
500
+ )
467
501
  raise EntityValidationError(
468
502
  f"Entity {name!r}: could not read schema from {static!r}: {exc}"
469
503
  ) from exc
470
504
 
471
505
  missing_keys = [c for c in entity.key_cols if c not in schema]
472
506
  if missing_keys:
507
+ logger.error(
508
+ "Validation failed for entity {}: key columns {} not found in schema.",
509
+ name,
510
+ missing_keys,
511
+ )
473
512
  raise EntityValidationError(
474
513
  f"Entity {name!r}: key_cols {missing_keys} not found in {static!r}. "
475
514
  f"Available columns: {list(schema.keys())}."
476
515
  )
477
516
 
478
517
  if entity.geometry_col not in schema:
518
+ logger.error(
519
+ "Validation failed for entity {}: geometry column '{}' not found in schema.",
520
+ name,
521
+ entity.geometry_col,
522
+ )
479
523
  raise EntityValidationError(
480
524
  f"Entity {name!r}: geometry_col {entity.geometry_col!r} "
481
525
  f"not found in {static!r}. Available columns: {list(schema.keys())}."
@@ -489,6 +533,12 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
489
533
  except FileNotFoundError:
490
534
  abs_path = None
491
535
  if abs_path is None or not os.path.exists(abs_path):
536
+ logger.error(
537
+ "Validation failed for entity {}: {} path '{}' does not exist.",
538
+ name,
539
+ label,
540
+ path,
541
+ )
492
542
  raise EntityValidationError(
493
543
  f"Entity {name!r}: {label}_path {path!r} does not exist."
494
544
  )
@@ -16,6 +16,7 @@ from abc import ABC, abstractmethod
16
16
  from typing import TYPE_CHECKING, Any
17
17
 
18
18
  import polars as pl
19
+ from loguru import logger
19
20
 
20
21
  from core_lens.utils.spatial import (
21
22
  bbox_intersects_geometry,
@@ -84,7 +85,7 @@ class BaseEntity(ABC):
84
85
  """Initialise the entity with an optional data root directory.
85
86
 
86
87
  Args:
87
- data_root: Absolute path to the root data directory. When
88
+ data_root (pathlib.Path | None, optional): Absolute path to the root data directory. When
88
89
  supplied, relative :attr:`static_path`, :attr:`annual_path`,
89
90
  and :attr:`fortnightly_path` values are resolved against this
90
91
  directory. Defaults to ``None``, in which case relative paths
@@ -100,10 +101,10 @@ class BaseEntity(ABC):
100
101
  otherwise against the current working directory.
101
102
 
102
103
  Args:
103
- path: A filesystem path, absolute or relative.
104
+ path (str): A filesystem path, absolute or relative.
104
105
 
105
106
  Returns:
106
- An absolute path string.
107
+ str: An absolute path string.
107
108
 
108
109
  Raises:
109
110
  FileNotFoundError: If the resolved path does not exist.
@@ -115,6 +116,9 @@ class BaseEntity(ABC):
115
116
  )
116
117
  p = root / p
117
118
  if not p.exists():
119
+ logger.error(
120
+ "Path resolution failed: '{}' (resolved to {}) does not exist.", path, p
121
+ )
118
122
  raise FileNotFoundError(
119
123
  f"Entity path {path!r} (resolved to {p}) does not exist. "
120
124
  "Provide an absolute path or ensure the file exists relative to "
@@ -131,7 +135,7 @@ class BaseEntity(ABC):
131
135
  ``[\"mws_id\"]``), but the contract allows composite keys for plugins.
132
136
 
133
137
  Returns:
134
- A list of column name strings present in the static file.
138
+ list[str]: A list of column name strings present in the static file.
135
139
  """
136
140
 
137
141
  @property
@@ -143,7 +147,7 @@ class BaseEntity(ABC):
143
147
  (WKB bytes, WKT string, or a native geometry column).
144
148
 
145
149
  Returns:
146
- The column name as a string.
150
+ str: The column name as a string.
147
151
  """
148
152
 
149
153
  @property
@@ -156,7 +160,7 @@ class BaseEntity(ABC):
156
160
  ``FileNotFoundError`` is raised if the file does not exist.
157
161
 
158
162
  Returns:
159
- A path string.
163
+ str: A path string.
160
164
  """
161
165
 
162
166
  @property
@@ -168,7 +172,7 @@ class BaseEntity(ABC):
168
172
  :class:`EntityValidationError` is raised.
169
173
 
170
174
  Returns:
171
- A path string, or ``None`` if the entity has no annual data.
175
+ str | None: A path string, or ``None`` if the entity has no annual data.
172
176
  """
173
177
  return None
174
178
 
@@ -181,7 +185,7 @@ class BaseEntity(ABC):
181
185
  :class:`EntityValidationError` is raised.
182
186
 
183
187
  Returns:
184
- A path string, or ``None`` if the entity has no fortnightly data.
188
+ str | None: A path string, or ``None`` if the entity has no fortnightly data.
185
189
  """
186
190
  return None
187
191
 
@@ -194,7 +198,7 @@ class BaseEntity(ABC):
194
198
  instead of relying on detection.
195
199
 
196
200
  Returns:
197
- A fully-validated :class:`~core_lens.schema.profile.SchemaProfile`.
201
+ SchemaProfile: A fully-validated :class:`~core_lens.schema.profile.SchemaProfile`.
198
202
  """
199
203
  if not hasattr(self, "_schema_profile"):
200
204
  from core_lens.schema.detection import detect
@@ -219,6 +223,9 @@ class BaseEntity(ABC):
219
223
  @property
220
224
  def _index(self) -> pl.DataFrame:
221
225
  if not hasattr(self, "_cached_index"):
226
+ logger.debug(
227
+ "Building lazy bounding box index for {}", self.__class__.__name__
228
+ )
222
229
  profile = self.schema_profile
223
230
  self._cached_index: pl.DataFrame = build_bbox_index(
224
231
  static_path=self._resolve(self.static_path),
@@ -247,7 +254,7 @@ class BaseEntity(ABC):
247
254
  are resolved as entity-name lookups.
248
255
 
249
256
  Returns:
250
- A lazy :class:`~core_lens.base.view.View` with resolved key pairs.
257
+ View: A lazy :class:`~core_lens.base.view.View` with resolved key pairs.
251
258
 
252
259
  Raises:
253
260
  ValueError: If a kwarg cannot be resolved as either an attribute
@@ -256,6 +263,10 @@ class BaseEntity(ABC):
256
263
  from core_lens.aoi import _REGISTRY
257
264
  from core_lens.base.view import View
258
265
 
266
+ logger.debug(
267
+ "Applying attribute filters on {}: {}", self.__class__.__name__, kwargs
268
+ )
269
+
259
270
  static = self._resolve(self.static_path)
260
271
  schema = pl.read_parquet_schema(static)
261
272
 
@@ -265,6 +276,10 @@ class BaseEntity(ABC):
265
276
  # Validate entity-kwargs early so we give a useful error message.
266
277
  for k in entity_kwargs:
267
278
  if k not in _REGISTRY:
279
+ logger.error(
280
+ "BaseEntity.where failed: '{}' is neither a column nor a registered entity.",
281
+ k,
282
+ )
268
283
  raise ValueError(
269
284
  f"BaseEntity.where: {k!r} is not a column in {self.static_path!r} "
270
285
  f"and is not a registered entity name. "
@@ -306,6 +321,12 @@ class BaseEntity(ABC):
306
321
  )
307
322
 
308
323
  if matched.is_empty():
324
+ logger.error(
325
+ "BaseEntity.where failed: No rows matched {}={!r} in {}",
326
+ entity_kwarg_name,
327
+ entity_kwarg_val,
328
+ other_entity.static_path,
329
+ )
309
330
  raise ValueError(
310
331
  f"BaseEntity.where: No rows matched {entity_kwarg_name}={entity_kwarg_val!r} "
311
332
  f"in {other_entity.static_path!r}."
@@ -354,21 +375,21 @@ class BaseEntity(ABC):
354
375
  refines with a Shapely STRtree exact-relationship check.
355
376
 
356
377
  Args:
357
- geometry: A Shapely geometry representing the spatial extent.
358
- bbox: Bounding box as ``(minx, miny, maxx, maxy)`` in WGS-84.
378
+ geometry (shapely.Geometry | None, optional): A Shapely geometry representing the spatial extent.
379
+ bbox (tuple[float, float, float, float] | None, optional): Bounding box as ``(minx, miny, maxx, maxy)`` in WGS-84.
359
380
  Converted to a ``shapely.geometry.box`` internally.
360
- relationship: Spatial relationship mode.
381
+ relationship (str, optional): Spatial relationship mode.
361
382
 
362
383
  * ``"centroid"`` (default) — entity centroid must lie within
363
384
  the geometry.
364
385
  * ``"area"`` — intersection area / entity area must exceed
365
386
  ``threshold``.
366
387
 
367
- threshold: Area coverage threshold for ``"area"`` mode (0–1).
388
+ threshold (float, optional): Area coverage threshold for ``"area"`` mode (0–1).
368
389
  Default 0.5.
369
390
 
370
391
  Returns:
371
- A lazy :class:`~core_lens.base.view.View` scoped to the given
392
+ View: A lazy :class:`~core_lens.base.view.View` scoped to the given
372
393
  spatial extent.
373
394
 
374
395
  Raises:
@@ -379,6 +400,9 @@ class BaseEntity(ABC):
379
400
  from core_lens.base.view import View
380
401
 
381
402
  if geometry is None and bbox is None:
403
+ logger.error(
404
+ "BaseEntity.spatial_filter failed: neither 'geometry' nor 'bbox' provided."
405
+ )
382
406
  raise ValueError(
383
407
  "spatial_filter() requires either 'geometry' or 'bbox' to be provided."
384
408
  )
@@ -387,6 +411,12 @@ class BaseEntity(ABC):
387
411
 
388
412
  assert geometry is not None # guaranteed by the guards above
389
413
 
414
+ logger.debug(
415
+ "Applying spatial filter on {} (relationship='{}')",
416
+ self.__class__.__name__,
417
+ relationship,
418
+ )
419
+
390
420
  profile = self.schema_profile
391
421
  candidates = bbox_intersects_geometry(self._index, geometry)
392
422
  keys = exact_spatial_filter(
@@ -411,19 +441,25 @@ class BaseEntity(ABC):
411
441
  Joined columns are namespaced as ``{entity_name}_{column_name}``.
412
442
 
413
443
  Args:
414
- other: The secondary :class:`BaseEntity` whose columns will be
444
+ other (BaseEntity): The secondary :class:`BaseEntity` whose columns will be
415
445
  joined and aggregated onto ``self``.
416
- agg: Mapping of ``{column: aggregation}`` specifying which columns
446
+ agg (dict[str, str]): Mapping of ``{column: aggregation}`` specifying which columns
417
447
  from ``other`` to bring in and how to aggregate them. Valid
418
448
  aggregation strings are ``\"area\"``, ``\"count\"``, ``\"mean\"``,
419
449
  ``\"sum\"``, ``\"min\"``, and ``\"max\"``.
420
450
 
421
451
  Returns:
422
- A lazy :class:`~core_lens.base.view.View` with the join spec
452
+ View: A lazy :class:`~core_lens.base.view.View` with the join spec
423
453
  recorded for deferred execution.
424
454
  """
425
455
  from core_lens.base.view import View
426
456
 
457
+ logger.debug(
458
+ "Deferred spatial join registered for {} with {}",
459
+ self.__class__.__name__,
460
+ other.__class__.__name__,
461
+ )
462
+
427
463
  entity_name = _entity_name(type(self))
428
464
  join_spec = {"other": other, "agg": agg}
429
465
  return View(