core-lens 0.1.dev74__py3-none-any.whl

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.
core_lens/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ try:
2
+ from ._version import __version__
3
+ except ImportError:
4
+ __version__ = "0.1.0"
5
+
6
+ from core_lens.aoi import AoI, SeasonConfig
7
+ from core_lens.entities.tehsil import TehsilEntity
8
+
9
+ __all__ = ["__version__", "AoI", "SeasonConfig", "TehsilEntity"]
core_lens/__main__.py ADDED
@@ -0,0 +1,14 @@
1
+ from . import __version__
2
+
3
+
4
+ def hello() -> str:
5
+ return "Hello from core-lens!"
6
+
7
+
8
+ def version() -> str:
9
+ return str(__version__)
10
+
11
+
12
+ if __name__ == "__main__":
13
+ print(hello())
14
+ print(f"Version: {version()}")
core_lens/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.dev74'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev74')
23
+
24
+ __commit_id__ = commit_id = None
core_lens/aoi.py ADDED
@@ -0,0 +1,503 @@
1
+ """AoI (Area of Interest) — primary entry point for core_lens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import pathlib
7
+ from dataclasses import dataclass
8
+ from datetime import date
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ if TYPE_CHECKING:
12
+ from core_lens.base.result import Result
13
+
14
+ import polars as pl
15
+
16
+ from core_lens.base.entity import BaseEntity, EntityValidationError
17
+
18
+ if TYPE_CHECKING:
19
+ import shapely
20
+ from core_lens.base.view import View
21
+
22
+
23
+ @dataclass
24
+ class SeasonConfig:
25
+ """Date-range definitions for the three Indian crop seasons.
26
+
27
+ Each season is a ``(MM-DD, MM-DD)`` inclusive range. Seasons that cross
28
+ the calendar year-end (e.g. rabi: Nov → Mar) are handled by comparing
29
+ the month-day portion of a date against each range, rolling over where
30
+ necessary.
31
+
32
+ The library ships with agronomic defaults for the Indo-Gangetic plain.
33
+ Override at AoI construction time to match a different agro-climatic zone::
34
+
35
+ aoi = AoI("data/", district="Dharwad", seasons=SeasonConfig(
36
+ kharif=("06-01", "10-15"),
37
+ rabi=("10-16", "02-28"),
38
+ zaid=("03-01", "05-31"),
39
+ ))
40
+
41
+ Attributes:
42
+ kharif: Kharif (monsoon) season range as ``(start_MM-DD, end_MM-DD)``.
43
+ rabi: Rabi (winter) season range.
44
+ zaid: Zaid (summer) season range.
45
+ """
46
+
47
+ kharif: tuple[str, str] = ("07-01", "10-30")
48
+ rabi: tuple[str, str] = ("11-01", "03-31")
49
+ zaid: tuple[str, str] = ("04-01", "06-30")
50
+
51
+ def __post_init__(self) -> None:
52
+ from datetime import datetime
53
+
54
+ for attr in ("kharif", "rabi", "zaid"):
55
+ start, end = getattr(self, attr)
56
+ try:
57
+ # Use a leap year (2000) to allow "02-29"
58
+ datetime.strptime(f"2000-{start}", "%Y-%m-%d")
59
+ datetime.strptime(f"2000-{end}", "%Y-%m-%d")
60
+ except ValueError as e:
61
+ raise ValueError(
62
+ f"SeasonConfig: Invalid date format for {attr}=('{start}', '{end}'). "
63
+ f"Expected valid 'MM-DD' strings. Original error: {e}"
64
+ ) from e
65
+
66
+ def season_for(self, d: date) -> str:
67
+ """Return the season name for a given date.
68
+
69
+ Args:
70
+ d: The date to classify.
71
+
72
+ Returns:
73
+ ``"kharif"``, ``"rabi"``, or ``"zaid"``.
74
+ """
75
+ md = f"{d.month:02d}-{d.day:02d}"
76
+ for name in ("kharif", "rabi", "zaid"):
77
+ start, end = getattr(self, name)
78
+ if _md_in_range(md, start, end):
79
+ return name
80
+ # A date that falls in no season (can happen if ranges have gaps) is
81
+ # assigned to the nearest season. In practice the defaults cover the
82
+ # whole year, so this branch should never fire with library defaults.
83
+ raise ValueError(
84
+ f"Date {d} does not fall within any configured season. "
85
+ "Ensure the three SeasonConfig ranges together cover the full year."
86
+ )
87
+
88
+
89
+ def _md_in_range(md: str, start: str, end: str) -> bool:
90
+ """Return True if a MM-DD string falls within [start, end], handling year rollover."""
91
+ if start <= end:
92
+ return start <= md <= end
93
+ # Year-crossing range (e.g. rabi: 11-01 to 03-31)
94
+ return md >= start or md <= end
95
+
96
+
97
+ # Class-level registry: entity name → entity **class**.
98
+ # Shared across all AoI instances in a process. Explicit registration is
99
+ # required; there is no auto-discovery (design §6.1).
100
+ _REGISTRY: dict[str, type[BaseEntity]] = {}
101
+
102
+
103
+ class AoI:
104
+ """Area of Interest — the primary entry point for querying geospatial data.
105
+
106
+ An ``AoI`` is two things simultaneously:
107
+
108
+ 1. **A geometry** — the resolved boundary of the named administrative unit
109
+ (or a raw bbox / Shapely polygon), stored as :attr:`geometry`.
110
+ 2. **A collection of scoped entities** — every registered entity
111
+ pre-filtered to instances that fall within :attr:`geometry`. Accessed
112
+ as attributes: ``aoi.mws``, ``aoi.village``, ``aoi.forest``, etc.
113
+
114
+ ``AoI`` holds no data itself. Entity attributes are lazy
115
+ :class:`~core_lens.base.view.View` objects; no Parquet I/O occurs until a
116
+ materialisation property (``.static``, ``.annual``, ``.fortnightly``) is
117
+ accessed on a View.
118
+
119
+ **Registration** must happen before any ``AoI`` is constructed::
120
+
121
+ from core_lens import AoI, MWSEntity, TehsilEntity
122
+
123
+ AoI.register(MWSEntity)
124
+ AoI.register(TehsilEntity)
125
+
126
+ **Initialisation** — exactly one boundary argument is required::
127
+
128
+ aoi = AoI("data/", tehsil="Pangi", district="Chamba", state="Himachal Pradesh")
129
+ aoi = AoI("data/", bbox=(minx, miny, maxx, maxy))
130
+ aoi = AoI("data/", geometry=some_shapely_polygon)
131
+ aoi = AoI("data/", village="Shiroor")
132
+ aoi = AoI("data/", mws_id="13_551")
133
+
134
+ **Entity access**::
135
+
136
+ aoi.mws # View — all MWS within the AoI boundary
137
+ aoi.tehsil # View — all tehsils within the AoI boundary
138
+ aoi.forest # View — plugin entity (if registered)
139
+
140
+ Attributes:
141
+ data_root: Resolved path to the data directory.
142
+ geometry: Shapely polygon representing the AoI boundary.
143
+ seasons: :class:`SeasonConfig` in effect for this AoI.
144
+ """
145
+
146
+ def __init__(
147
+ self,
148
+ data_root: str,
149
+ *,
150
+ bbox: tuple[float, float, float, float] | None = None,
151
+ geometry: "shapely.Geometry | None" = None,
152
+ seasons: SeasonConfig | None = None,
153
+ **entity_kwargs: str,
154
+ ) -> None:
155
+ """Resolve the AoI boundary and scope all registered entities.
156
+
157
+ Args:
158
+ data_root: Path to the root data directory. Prepended to every
159
+ relative entity path that does not start with ``/`` or a URI
160
+ scheme.
161
+ bbox: Bounding box as ``(minx, miny, maxx, maxy)`` in WGS-84.
162
+ Mutually exclusive with ``geometry`` and ``entity_kwargs``.
163
+ geometry: Arbitrary Shapely geometry. Used as-is. Mutually
164
+ exclusive with ``bbox`` and ``entity_kwargs``.
165
+ seasons: :class:`SeasonConfig` override. Defaults to the library
166
+ agronomic defaults.
167
+ **entity_kwargs: Named filter pairs that identify the boundary,
168
+ e.g. ``tehsil="Pangi"``, ``district="Chamba"``,
169
+ ``state="Himachal Pradesh"``, ``mws_id="13_551"``.
170
+ Mutually exclusive with ``bbox`` and ``geometry``.
171
+
172
+ Raises:
173
+ ValueError: If no boundary argument is supplied, or if more than
174
+ one boundary mode is used simultaneously.
175
+ :class:`~core_lens.base.EntityValidationError`: If a boundary entity referenced in
176
+ ``entity_kwargs`` is not registered.
177
+ """
178
+ self.data_root = pathlib.Path(data_root).resolve()
179
+ self.seasons: SeasonConfig = seasons or SeasonConfig()
180
+
181
+ n_modes = sum(
182
+ [
183
+ bbox is not None,
184
+ geometry is not None,
185
+ bool(entity_kwargs),
186
+ ]
187
+ )
188
+ if n_modes == 0:
189
+ raise ValueError(
190
+ "AoI requires exactly one boundary argument: "
191
+ "bbox=, geometry=, or entity keyword filters such as tehsil='Pangi'."
192
+ )
193
+ if n_modes > 1:
194
+ raise ValueError(
195
+ "bbox, geometry, and entity keyword filters are mutually exclusive. "
196
+ "Provide exactly one boundary mode."
197
+ )
198
+
199
+ # Instantiate and validate all registered entities with this data_root.
200
+ # This is where relative paths are resolved and checked.
201
+ self._entity_instances: dict[str, BaseEntity] = {}
202
+ for name, entity_cls in _REGISTRY.items():
203
+ entity = entity_cls(data_root=self.data_root)
204
+ _validate_entity(entity, name)
205
+ self._entity_instances[name] = entity
206
+
207
+ if geometry is not None:
208
+ self.geometry: "shapely.Geometry" = geometry
209
+ elif bbox is not None:
210
+ self.geometry = _bbox_to_polygon(bbox)
211
+ else:
212
+ self.geometry = self._resolve_named_boundary(entity_kwargs)
213
+
214
+ # Entity views are created lazily on demand in __getattr__.
215
+ self._scoped: dict[str, "View"] = {}
216
+
217
+ @property
218
+ def current_season(self) -> str:
219
+ """The season name for today's date under the AoI's SeasonConfig.
220
+
221
+ Returns:
222
+ ``"kharif"``, ``"rabi"``, or ``"zaid"``.
223
+ """
224
+ return self.seasons.season_for(date.today())
225
+
226
+ @property
227
+ def current_year(self) -> int:
228
+ """The current calendar year.
229
+
230
+ Returns:
231
+ Current year as an integer.
232
+ """
233
+ return date.today().year
234
+
235
+ def plot(self, overlay: "Result | None" = None) -> Any:
236
+ """Render an interactive Lonboard map of the AoI and its entity layers.
237
+
238
+ Args:
239
+ overlay: An optional :class:`~core_lens.base.result.Result` to
240
+ overlay on the map.
241
+
242
+ Returns:
243
+ A Lonboard Map object.
244
+ """
245
+ import lonboard
246
+ import geopandas as gpd
247
+
248
+ layers = []
249
+
250
+ aoi_gdf = gpd.GeoDataFrame(geometry=[self.geometry], crs="EPSG:4326")
251
+ base_layer = lonboard.PolygonLayer.from_geopandas(
252
+ aoi_gdf,
253
+ get_fill_color=[0, 0, 0, 0],
254
+ get_line_color=[0, 0, 0, 255],
255
+ line_width_min_pixels=2,
256
+ )
257
+ layers.append(base_layer)
258
+
259
+ if overlay is not None:
260
+ # We duck-type the overlay to avoid circular imports of Result
261
+ if hasattr(overlay, "has_geometry") and not overlay.has_geometry:
262
+ overlay = overlay.with_geometry()
263
+
264
+ if hasattr(overlay, "gdf"):
265
+ gdf = overlay.gdf()
266
+
267
+ # Lonboard PolygonLayer only supports Polygon and MultiPolygon.
268
+ # Extract polygon parts from GeometryCollections rather than dropping them.
269
+ def _extract_polygons(geom: Any) -> Any:
270
+ if geom is None:
271
+ return None
272
+ if geom.geom_type in ("Polygon", "MultiPolygon"):
273
+ return geom
274
+ if geom.geom_type == "GeometryCollection":
275
+ from shapely.geometry import MultiPolygon
276
+
277
+ polys = [
278
+ g
279
+ for g in getattr(geom, "geoms", [])
280
+ if g.geom_type in ("Polygon", "MultiPolygon")
281
+ ]
282
+ if polys:
283
+ return MultiPolygon(polys)
284
+ return None
285
+
286
+ gdf["geometry"] = gdf.geometry.apply(_extract_polygons)
287
+ gdf = gdf[gdf.geometry.notna()].copy()
288
+
289
+ overlay_layer = lonboard.PolygonLayer.from_geopandas(
290
+ gdf,
291
+ get_fill_color=[255, 0, 0, 100],
292
+ get_line_color=[255, 0, 0, 200],
293
+ line_width_min_pixels=1,
294
+ )
295
+ layers.append(overlay_layer)
296
+
297
+ return lonboard.Map(layers=layers)
298
+
299
+ def __getattr__(self, name: str) -> "View":
300
+ # Called only when normal attribute lookup has already failed, so this
301
+ # never shadows real attributes. Maps entity names to their scoped Views.
302
+ if name in _REGISTRY:
303
+ if name not in self._scoped:
304
+ entity = self._entity_instances[name]
305
+ view = entity.spatial_filter(geometry=self.geometry)
306
+ view._season_config = self.seasons
307
+ self._scoped[name] = view
308
+ return self._scoped[name]
309
+ raise AttributeError(
310
+ f"'AoI' object has no attribute {name!r}. "
311
+ f"Registered entities: {sorted(_REGISTRY)}. "
312
+ "Use AoI.register(EntityClass) to add a new entity."
313
+ )
314
+
315
+ def _resolve_named_boundary(
316
+ self, entity_kwargs: dict[str, str]
317
+ ) -> "shapely.Geometry":
318
+ """Resolve a set of named attribute filters to a Shapely geometry.
319
+
320
+ The entity whose key column matches one of the kwargs is queried.
321
+ Multiple kwargs act as AND-filters (e.g. tehsil + district narrows to
322
+ the unique matching row).
323
+
324
+ Args:
325
+ entity_kwargs: Column–value pairs used to identify the boundary.
326
+
327
+ Returns:
328
+ The union of all matching entity geometries as a Shapely object.
329
+
330
+ Raises:
331
+ :class:`~core_lens.base.EntityValidationError`: If no registered entity can satisfy the filters.
332
+ ValueError: If the filters match zero rows.
333
+ """
334
+ import shapely.ops as sops
335
+ import shapely.wkb as swkb
336
+
337
+ # Find the registered entity whose key_col or known attribute column
338
+ # matches one of the filter keys.
339
+ candidate: BaseEntity | None = None
340
+ for name, entity in self._entity_instances.items():
341
+ schema = entity.schema_profile
342
+ if any(
343
+ k in schema.key_cols or k in schema.extra_static_cols
344
+ for k in entity_kwargs
345
+ ):
346
+ candidate = entity
347
+ break
348
+
349
+ # Fall back: look for an entity whose name matches a kwarg key
350
+ # (e.g. tehsil="Pangi" → TehsilEntity if registered as "tehsil").
351
+ if candidate is None:
352
+ for name, entity in self._entity_instances.items():
353
+ if name in entity_kwargs:
354
+ candidate = entity
355
+ break
356
+
357
+ if candidate is None:
358
+ raise EntityValidationError(
359
+ f"No registered entity can satisfy the filters {entity_kwargs}. "
360
+ f"Registered entities: {sorted(_REGISTRY)}."
361
+ )
362
+
363
+ schema = candidate.schema_profile
364
+ geom_col = schema.geometry_col
365
+
366
+ # Build a lazy frame to push filters down into the Parquet reader.
367
+ # This avoids loading the entire geometry column into memory.
368
+ lf = pl.scan_parquet(candidate._resolve(candidate.static_path))
369
+
370
+ filter_expr = pl.lit(True)
371
+ for col, val in entity_kwargs.items():
372
+ if (
373
+ col in candidate.schema_profile.key_cols
374
+ or col in candidate.schema_profile.extra_static_cols
375
+ ):
376
+ filter_expr = filter_expr & (pl.col(col) == val)
377
+
378
+ df = lf.filter(filter_expr).select(candidate.key_cols + [geom_col]).collect()
379
+
380
+ if df.is_empty():
381
+ raise ValueError(
382
+ f"No rows matched the filters {entity_kwargs} "
383
+ f"in {candidate.static_path!r}."
384
+ )
385
+
386
+ geoms = [swkb.loads(row) for row in df[geom_col].to_list()]
387
+ return sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
388
+
389
+ @classmethod
390
+ def register(cls, entity_cls: type[BaseEntity]) -> None:
391
+ """Register an entity class so it is available on all future AoI instances.
392
+
393
+ The entity name is derived from the class name by stripping a trailing
394
+ ``"Entity"`` suffix and lower-casing the result
395
+ (``MWSEntity`` → ``"mws"``, ``ForestEntity`` → ``"forest"``).
396
+
397
+ For entities with **absolute** paths, validation (file existence, key cols,
398
+ geometry col) runs immediately at registration time.
399
+ For entities with **relative** paths, validation is deferred until an
400
+ :class:`AoI` is instantiated (when ``data_root`` is known).
401
+
402
+ Args:
403
+ entity_cls: A concrete subclass of
404
+ :class:`~core_lens.base.entity.BaseEntity`.
405
+
406
+ Raises:
407
+ :class:`~core_lens.base.EntityValidationError`: If any validation check
408
+ fails (absolute-path entities only at register time).
409
+ """
410
+ import pathlib as _pathlib
411
+
412
+ name = _entity_name(entity_cls)
413
+ # Probe with a no-root instance to check if paths are absolute.
414
+ probe = entity_cls()
415
+ static = probe.static_path
416
+ if _pathlib.Path(static).is_absolute():
417
+ # Absolute path entity: validate now.
418
+ _validate_entity(probe, name)
419
+ # Relative path entity: validation deferred to AoI.__init__.
420
+ _REGISTRY[name] = entity_cls
421
+
422
+ @classmethod
423
+ def deregister(cls, entity_cls: type[BaseEntity]) -> None:
424
+ """Remove a previously registered entity.
425
+
426
+ Primarily useful in tests where a clean registry is needed between runs.
427
+
428
+ Args:
429
+ entity_cls: The entity class to remove.
430
+ """
431
+ name = _entity_name(entity_cls)
432
+ _REGISTRY.pop(name, None)
433
+
434
+ @classmethod
435
+ def registered_entities(cls) -> list[str]:
436
+ """Return the names of all currently registered entities.
437
+
438
+ Returns:
439
+ A sorted list of entity name strings.
440
+ """
441
+ return sorted(_REGISTRY)
442
+
443
+
444
+ def _entity_name(entity_cls: type[BaseEntity]) -> str:
445
+ name = entity_cls.__name__
446
+ if name.endswith("Entity"):
447
+ name = name[: -len("Entity")]
448
+ return name.lower()
449
+
450
+
451
+ def _bbox_to_polygon(
452
+ bbox: tuple[float, float, float, float],
453
+ ) -> "shapely.Geometry":
454
+ import shapely.geometry as sgeom
455
+
456
+ minx, miny, maxx, maxy = bbox
457
+ return sgeom.box(minx, miny, maxx, maxy)
458
+
459
+
460
+ def _validate_entity(entity: BaseEntity, name: str) -> None:
461
+ try:
462
+ static = entity._resolve(entity.static_path)
463
+ except FileNotFoundError:
464
+ raise EntityValidationError(
465
+ f"Entity {name!r}: static_path {entity.static_path!r} does not exist."
466
+ )
467
+
468
+ if not os.path.exists(static):
469
+ raise EntityValidationError(
470
+ f"Entity {name!r}: static_path {static!r} does not exist."
471
+ )
472
+
473
+ try:
474
+ schema = pl.read_parquet_schema(static)
475
+ except Exception as exc:
476
+ raise EntityValidationError(
477
+ f"Entity {name!r}: could not read schema from {static!r}: {exc}"
478
+ ) from exc
479
+
480
+ missing_keys = [c for c in entity.key_cols if c not in schema]
481
+ if missing_keys:
482
+ raise EntityValidationError(
483
+ f"Entity {name!r}: key_cols {missing_keys} not found in {static!r}. "
484
+ f"Available columns: {list(schema.keys())}."
485
+ )
486
+
487
+ if entity.geometry_col not in schema:
488
+ raise EntityValidationError(
489
+ f"Entity {name!r}: geometry_col {entity.geometry_col!r} "
490
+ f"not found in {static!r}. Available columns: {list(schema.keys())}."
491
+ )
492
+
493
+ for attr, label in [("annual_path", "annual"), ("fortnightly_path", "fortnightly")]:
494
+ path = getattr(entity, attr)
495
+ if path is not None:
496
+ try:
497
+ abs_path = entity._resolve(path)
498
+ except FileNotFoundError:
499
+ abs_path = None
500
+ if abs_path is None or not os.path.exists(abs_path):
501
+ raise EntityValidationError(
502
+ f"Entity {name!r}: {label}_path {path!r} does not exist."
503
+ )
@@ -0,0 +1,19 @@
1
+ """Public API surface for core_lens plugin authors.
2
+
3
+ Plugin authors should import only from this package::
4
+
5
+ from core_lens.base import BaseEntity, View, Result
6
+
7
+ Internal modules (namespaces, schema helpers) are not re-exported here.
8
+ """
9
+
10
+ from core_lens.base.entity import BaseEntity, EntityValidationError
11
+ from core_lens.base.view import View
12
+ from core_lens.base.result import Result
13
+
14
+ __all__ = [
15
+ "BaseEntity",
16
+ "EntityValidationError",
17
+ "Result",
18
+ "View",
19
+ ]