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 +9 -0
- core_lens/__main__.py +14 -0
- core_lens/_version.py +24 -0
- core_lens/aoi.py +503 -0
- core_lens/base/__init__.py +19 -0
- core_lens/base/entity.py +450 -0
- core_lens/base/namespaces/__init__.py +6 -0
- core_lens/base/namespaces/plot.py +569 -0
- core_lens/base/namespaces/stats.py +906 -0
- core_lens/base/result.py +291 -0
- core_lens/base/view.py +431 -0
- core_lens/entities/__init__.py +6 -0
- core_lens/entities/mws.py +33 -0
- core_lens/entities/tehsil.py +46 -0
- core_lens/export/__init__.py +5 -0
- core_lens/export/formats.py +212 -0
- core_lens/py.typed +0 -0
- core_lens/schema/__init__.py +5 -0
- core_lens/schema/detection.py +220 -0
- core_lens/schema/profile.py +108 -0
- core_lens/utils/__init__.py +1 -0
- core_lens/utils/polars_utils.py +54 -0
- core_lens/utils/season.py +224 -0
- core_lens/utils/spatial.py +343 -0
- core_lens-0.1.dev74.dist-info/METADATA +107 -0
- core_lens-0.1.dev74.dist-info/RECORD +28 -0
- core_lens-0.1.dev74.dist-info/WHEEL +4 -0
- core_lens-0.1.dev74.dist-info/licenses/LICENSE +674 -0
core_lens/base/entity.py
ADDED
|
@@ -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()
|