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/__init__.py
ADDED
core_lens/__main__.py
ADDED
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
|
+
]
|