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.
@@ -0,0 +1,450 @@
1
+ """Base entity contract for core_lens.
2
+
3
+ All entities — built-in or plugin — must subclass :class:`BaseEntity` and
4
+ implement its abstract interface. Concrete implementations live in
5
+ ``core_lens.entities.*`` (built-ins) or in third-party packages (plugins).
6
+
7
+ Plugin authors import from the public surface::
8
+
9
+ from core_lens.base import BaseEntity
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import pathlib
15
+ from abc import ABC, abstractmethod
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import polars as pl
19
+
20
+ from core_lens.utils.spatial import (
21
+ bbox_intersects_geometry,
22
+ build_bbox_index,
23
+ exact_spatial_filter,
24
+ )
25
+
26
+ if TYPE_CHECKING:
27
+ import shapely
28
+ from core_lens.base.view import View
29
+ from core_lens.schema.profile import SchemaProfile
30
+
31
+
32
+ class BaseEntity(ABC):
33
+ """Abstract base class for every entity in the core_lens plugin system.
34
+
35
+ An entity represents a geospatial primitive (e.g. microwatershed, village,
36
+ district) backed by one or more Parquet/GeoParquet files. Entities are
37
+ *descriptors* — they carry path and schema metadata but hold no per-row
38
+ state themselves. Row-level data lives in :class:`~core_lens.base.view.View`
39
+ and :class:`~core_lens.base.result.Result`.
40
+
41
+ Subclasses **must** implement:
42
+
43
+ * :attr:`key_cols` — column(s) that uniquely identify one entity instance
44
+ * :attr:`geometry_col` — geometry column name in the static GeoParquet file
45
+ * :attr:`static_path` — path to the static GeoParquet file (mandatory).
46
+ May be relative; resolved against ``data_root`` when
47
+ the entity is instantiated by :class:`~core_lens.aoi.AoI`.
48
+
49
+ Subclasses **may** override:
50
+
51
+ * :attr:`annual_path` — path to the annual time-series Parquet file
52
+ * :attr:`fortnightly_path` — path to the fortnightly time-series Parquet file
53
+ * :attr:`schema_profile` — override auto-detection by returning an
54
+ explicit :class:`~core_lens.schema.profile.SchemaProfile`
55
+
56
+ ``where``, ``spatial_filter``, ``spatial_join``, and ``schema_profile`` are
57
+ all implemented on this base class. Subclasses only need to declare paths
58
+ and keys.
59
+
60
+ Plugin example::
61
+
62
+ from core_lens.base import BaseEntity
63
+
64
+ class ForestEntity(BaseEntity):
65
+ key_cols = [\"forest_patch_id\"]
66
+ geometry_col = \"geometry\"
67
+ static_path = \"forest/static.geoparquet\" # relative to AoI data_root
68
+ annual_path = \"forest/annual.parquet\"
69
+
70
+ AoI.register(ForestEntity)
71
+
72
+ Validation rules enforced at :meth:`AoI` instantiation time (relative paths)
73
+ or at :meth:`AoI.register` time (absolute paths):
74
+
75
+ 1. ``static_path`` exists and is readable.
76
+ 2. ``key_cols`` are present and unique in the static file.
77
+ 3. ``geometry_col`` is present and contains valid geometries.
78
+ 4. ``annual_path`` and ``fortnightly_path`` exist if declared.
79
+
80
+ Any failure raises :class:`~core_lens.base.entity.EntityValidationError`.
81
+ """
82
+
83
+ def __init__(self, data_root: pathlib.Path | None = None) -> None:
84
+ """Initialise the entity with an optional data root directory.
85
+
86
+ Args:
87
+ data_root: Absolute path to the root data directory. When
88
+ supplied, relative :attr:`static_path`, :attr:`annual_path`,
89
+ and :attr:`fortnightly_path` values are resolved against this
90
+ directory. Defaults to ``None``, in which case relative paths
91
+ are resolved against the current working directory (legacy
92
+ behaviour, preserved for plugin authors that use absolute paths).
93
+ """
94
+ self._data_root = data_root
95
+
96
+ def _resolve(self, path: str) -> str:
97
+ """Return an absolute path string for *path*.
98
+
99
+ Relative paths are resolved against :attr:`_data_root` if set,
100
+ otherwise against the current working directory.
101
+
102
+ Args:
103
+ path: A filesystem path, absolute or relative.
104
+
105
+ Returns:
106
+ An absolute path string.
107
+
108
+ Raises:
109
+ FileNotFoundError: If the resolved path does not exist.
110
+ """
111
+ p = pathlib.Path(path)
112
+ if not p.is_absolute():
113
+ root = (
114
+ self._data_root if self._data_root is not None else pathlib.Path.cwd()
115
+ )
116
+ p = root / p
117
+ if not p.exists():
118
+ raise FileNotFoundError(
119
+ f"Entity path {path!r} (resolved to {p}) does not exist. "
120
+ "Provide an absolute path or ensure the file exists relative to "
121
+ "the AoI data_root directory."
122
+ )
123
+ return str(p)
124
+
125
+ @property
126
+ @abstractmethod
127
+ def key_cols(self) -> list[str]:
128
+ """Columns that uniquely identify one instance of this entity.
129
+
130
+ For built-in entities this is always a single-element list (e.g.
131
+ ``[\"mws_id\"]``), but the contract allows composite keys for plugins.
132
+
133
+ Returns:
134
+ A list of column name strings present in the static file.
135
+ """
136
+
137
+ @property
138
+ @abstractmethod
139
+ def geometry_col(self) -> str:
140
+ """Name of the geometry column in the static GeoParquet file.
141
+
142
+ The column must contain a geometry type understood by GeoPandas
143
+ (WKB bytes, WKT string, or a native geometry column).
144
+
145
+ Returns:
146
+ The column name as a string.
147
+ """
148
+
149
+ @property
150
+ @abstractmethod
151
+ def static_path(self) -> str:
152
+ """Absolute filesystem path to the static GeoParquet file.
153
+
154
+ The path must be absolute. If a relative path is provided it is
155
+ resolved against the current working directory at first use. A
156
+ ``FileNotFoundError`` is raised if the file does not exist.
157
+
158
+ Returns:
159
+ A path string.
160
+ """
161
+
162
+ @property
163
+ def annual_path(self) -> str | None:
164
+ """Path to the annual time-series Parquet file, or ``None``.
165
+
166
+ Override in subclasses that carry annual temporal data. If declared,
167
+ the file must exist at :meth:`AoI.register` time or
168
+ :class:`EntityValidationError` is raised.
169
+
170
+ Returns:
171
+ A path string, or ``None`` if the entity has no annual data.
172
+ """
173
+ return None
174
+
175
+ @property
176
+ def fortnightly_path(self) -> str | None:
177
+ """Path to the fortnightly time-series Parquet file, or ``None``.
178
+
179
+ Override in subclasses that carry fortnightly temporal data. If
180
+ declared, the file must exist at :meth:`AoI.register` time or
181
+ :class:`EntityValidationError` is raised.
182
+
183
+ Returns:
184
+ A path string, or ``None`` if the entity has no fortnightly data.
185
+ """
186
+ return None
187
+
188
+ @property
189
+ def schema_profile(self) -> "SchemaProfile":
190
+ """Validated schema descriptor for this entity's data files.
191
+
192
+ Auto-detected from Parquet file metadata on first access and cached on
193
+ the instance. Override in subclasses to provide an explicit profile
194
+ instead of relying on detection.
195
+
196
+ Returns:
197
+ A fully-validated :class:`~core_lens.schema.profile.SchemaProfile`.
198
+ """
199
+ if not hasattr(self, "_schema_profile"):
200
+ from core_lens.schema.detection import detect
201
+
202
+ self._schema_profile: SchemaProfile = detect(
203
+ static_path=self._resolve(self.static_path),
204
+ key_cols=self.key_cols,
205
+ geometry_col=self.geometry_col,
206
+ annual_path=(
207
+ self._resolve(self.annual_path)
208
+ if self.annual_path is not None
209
+ else None
210
+ ),
211
+ fortnightly_path=(
212
+ self._resolve(self.fortnightly_path)
213
+ if self.fortnightly_path is not None
214
+ else None
215
+ ),
216
+ )
217
+ return self._schema_profile
218
+
219
+ @property
220
+ def _index(self) -> pl.DataFrame:
221
+ if not hasattr(self, "_cached_index"):
222
+ profile = self.schema_profile
223
+ self._cached_index: pl.DataFrame = build_bbox_index(
224
+ static_path=self._resolve(self.static_path),
225
+ key_cols=self.key_cols,
226
+ bbox_cols=profile.bbox_cols,
227
+ geometry_col=profile.geometry_col,
228
+ geometry_type=profile.geometry_type,
229
+ )
230
+ return self._cached_index
231
+
232
+ def where(self, **kwargs: Any) -> "View":
233
+ """Return a lazy :class:`~core_lens.base.view.View` filtered by attributes.
234
+
235
+ Each keyword argument is interpreted **attribute-first**: if the kwarg
236
+ key exists as a column in the static file the filter is applied
237
+ directly. If a kwarg key does *not* exist as a column it is resolved
238
+ as a registered entity name and the matching entity's geometry is used
239
+ for a spatial filter (e.g. ``district="Shimla"`` finds all MWS whose
240
+ centroid falls within Shimla district).
241
+
242
+ Multiple attribute kwargs are AND-ed. Multiple spatial-entity kwargs
243
+ are AND-ed via sequential spatial filters.
244
+
245
+ Args:
246
+ **kwargs: Column–value pairs to filter on. Unknown column names
247
+ are resolved as entity-name lookups.
248
+
249
+ Returns:
250
+ A lazy :class:`~core_lens.base.view.View` with resolved key pairs.
251
+
252
+ Raises:
253
+ ValueError: If a kwarg cannot be resolved as either an attribute
254
+ column or a registered entity name.
255
+ """
256
+ from core_lens.aoi import _REGISTRY
257
+ from core_lens.base.view import View
258
+
259
+ static = self._resolve(self.static_path)
260
+ schema = pl.read_parquet_schema(static)
261
+
262
+ attr_kwargs = {k: v for k, v in kwargs.items() if k in schema}
263
+ entity_kwargs = {k: v for k, v in kwargs.items() if k not in schema}
264
+
265
+ # Validate entity-kwargs early so we give a useful error message.
266
+ for k in entity_kwargs:
267
+ if k not in _REGISTRY:
268
+ raise ValueError(
269
+ f"BaseEntity.where: {k!r} is not a column in {self.static_path!r} "
270
+ f"and is not a registered entity name. "
271
+ f"Registered entities: {sorted(_REGISTRY)}. "
272
+ f"Available columns: {sorted(schema)}."
273
+ )
274
+
275
+ # --- Attribute filter -----------------------------------------------
276
+ if attr_kwargs:
277
+ filter_expr = pl.lit(True)
278
+ for col, val in attr_kwargs.items():
279
+ filter_expr = filter_expr & (pl.col(col) == val)
280
+ keys = (
281
+ pl.scan_parquet(static)
282
+ .filter(filter_expr)
283
+ .select(self.key_cols)
284
+ .collect()
285
+ )
286
+ else:
287
+ # No attribute filter: start with all entities.
288
+ keys = pl.scan_parquet(static).select(self.key_cols).collect()
289
+
290
+ # --- Spatial entity lookups -----------------------------------------
291
+ for entity_kwarg_name, entity_kwarg_val in entity_kwargs.items():
292
+ other_entity = _REGISTRY[entity_kwarg_name](data_root=self._data_root)
293
+ other_profile = other_entity.schema_profile
294
+ other_static = other_entity._resolve(other_entity.static_path)
295
+ import shapely.wkb as swkb
296
+ import shapely.ops as sops
297
+
298
+ # Find the geometry of the named entity.
299
+ lf = pl.scan_parquet(other_static)
300
+ match_expr = pl.lit(False)
301
+ for col in other_entity.key_cols + list(other_profile.extra_static_cols):
302
+ if col in pl.read_parquet_schema(other_static):
303
+ match_expr = match_expr | (pl.col(col) == entity_kwarg_val)
304
+ matched = (
305
+ lf.filter(match_expr).select([other_profile.geometry_col]).collect()
306
+ )
307
+
308
+ if matched.is_empty():
309
+ raise ValueError(
310
+ f"BaseEntity.where: No rows matched {entity_kwarg_name}={entity_kwarg_val!r} "
311
+ f"in {other_entity.static_path!r}."
312
+ )
313
+
314
+ raw_geoms = matched[other_profile.geometry_col].to_list()
315
+ if other_profile.geometry_type == "wkb":
316
+ geoms = [swkb.loads(v) for v in raw_geoms]
317
+ else:
318
+ import shapely.wkt as swkt
319
+
320
+ geoms = [swkt.loads(v) for v in raw_geoms]
321
+ lookup_geom = sops.unary_union(geoms) if len(geoms) > 1 else geoms[0]
322
+
323
+ # Spatial filter: narrow keys to those whose centroid is within the geometry.
324
+ from core_lens.utils.spatial import (
325
+ bbox_intersects_geometry,
326
+ exact_spatial_filter,
327
+ )
328
+
329
+ current_keys = self._index.join(keys, on=self.key_cols, how="inner")
330
+ candidates = bbox_intersects_geometry(current_keys, lookup_geom)
331
+ keys = exact_spatial_filter(
332
+ candidates=candidates,
333
+ static_path=static,
334
+ key_cols=self.key_cols,
335
+ geometry_col=self.schema_profile.geometry_col,
336
+ geometry_type=self.schema_profile.geometry_type,
337
+ aoi_geometry=lookup_geom,
338
+ relationship="centroid",
339
+ )
340
+
341
+ entity_name = _entity_name(type(self))
342
+ return View(keys=keys, entity=self, entity_name=entity_name)
343
+
344
+ def spatial_filter(
345
+ self,
346
+ geometry: "shapely.Geometry | None" = None,
347
+ bbox: tuple[float, float, float, float] | None = None,
348
+ relationship: str = "centroid",
349
+ threshold: float = 0.5,
350
+ ) -> "View":
351
+ """Return a lazy :class:`~core_lens.base.view.View` filtered by geometry.
352
+
353
+ Uses the in-memory bbox index for a fast rectangular pre-filter, then
354
+ refines with a Shapely STRtree exact-relationship check.
355
+
356
+ Args:
357
+ geometry: A Shapely geometry representing the spatial extent.
358
+ bbox: Bounding box as ``(minx, miny, maxx, maxy)`` in WGS-84.
359
+ Converted to a ``shapely.geometry.box`` internally.
360
+ relationship: Spatial relationship mode.
361
+
362
+ * ``"centroid"`` (default) — entity centroid must lie within
363
+ the geometry.
364
+ * ``"area"`` — intersection area / entity area must exceed
365
+ ``threshold``.
366
+
367
+ threshold: Area coverage threshold for ``"area"`` mode (0–1).
368
+ Default 0.5.
369
+
370
+ Returns:
371
+ A lazy :class:`~core_lens.base.view.View` scoped to the given
372
+ spatial extent.
373
+
374
+ Raises:
375
+ ValueError: If neither ``geometry`` nor ``bbox`` is provided.
376
+ """
377
+ import shapely.geometry as sgeom
378
+
379
+ from core_lens.base.view import View
380
+
381
+ if geometry is None and bbox is None:
382
+ raise ValueError(
383
+ "spatial_filter() requires either 'geometry' or 'bbox' to be provided."
384
+ )
385
+ if bbox is not None and geometry is None:
386
+ geometry = sgeom.box(*bbox)
387
+
388
+ assert geometry is not None # guaranteed by the guards above
389
+
390
+ profile = self.schema_profile
391
+ candidates = bbox_intersects_geometry(self._index, geometry)
392
+ keys = exact_spatial_filter(
393
+ candidates=candidates,
394
+ static_path=self._resolve(self.static_path),
395
+ key_cols=self.key_cols,
396
+ geometry_col=profile.geometry_col,
397
+ geometry_type=profile.geometry_type,
398
+ aoi_geometry=geometry,
399
+ relationship=relationship,
400
+ threshold=threshold,
401
+ )
402
+
403
+ entity_name = _entity_name(type(self))
404
+ return View(keys=keys, entity=self, entity_name=entity_name)
405
+
406
+ def spatial_join(self, other: "BaseEntity", agg: dict[str, str]) -> "View":
407
+ """Return a lazy :class:`~core_lens.base.view.View` with a cross-entity join pending.
408
+
409
+ The join is recorded in the View's ``join_spec`` and computed only at
410
+ materialisation time (``.static``, ``.annual``, or ``.fortnightly``).
411
+ Joined columns are namespaced as ``{entity_name}_{column_name}``.
412
+
413
+ Args:
414
+ other: The secondary :class:`BaseEntity` whose columns will be
415
+ joined and aggregated onto ``self``.
416
+ agg: Mapping of ``{column: aggregation}`` specifying which columns
417
+ from ``other`` to bring in and how to aggregate them. Valid
418
+ aggregation strings are ``\"area\"``, ``\"count\"``, ``\"mean\"``,
419
+ ``\"sum\"``, ``\"min\"``, and ``\"max\"``.
420
+
421
+ Returns:
422
+ A lazy :class:`~core_lens.base.view.View` with the join spec
423
+ recorded for deferred execution.
424
+ """
425
+ from core_lens.base.view import View
426
+
427
+ entity_name = _entity_name(type(self))
428
+ join_spec = {"other": other, "agg": agg}
429
+ return View(
430
+ keys=self._index.select(self.key_cols),
431
+ entity=self,
432
+ entity_name=entity_name,
433
+ join_spec=join_spec,
434
+ )
435
+
436
+
437
+ class EntityValidationError(Exception):
438
+ """Raised when an entity fails validation at :meth:`AoI.register` time.
439
+
440
+ The message will describe exactly which check failed (missing file, absent
441
+ key column, invalid geometry column, etc.) to give plugin authors
442
+ actionable feedback.
443
+ """
444
+
445
+
446
+ def _entity_name(entity_cls: type[BaseEntity]) -> str:
447
+ name = entity_cls.__name__
448
+ if name.endswith("Entity"):
449
+ name = name[: -len("Entity")]
450
+ return name.lower()
@@ -0,0 +1,6 @@
1
+ __module__: str = "core_lens.base.namespaces"
2
+
3
+ from core_lens.base.namespaces.plot import PlotNamespace
4
+ from core_lens.base.namespaces.stats import StatsNamespace
5
+
6
+ __all__ = ["PlotNamespace", "StatsNamespace"]