core-lens 0.1.dev133__tar.gz → 0.1.dev135__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.
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/PKG-INFO +4 -1
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/pyproject.toml +1 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/aoi.py +127 -43
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/export/formats.py +43 -13
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_aoi.py +6 -16
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/uv.lock +309 -272
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.gitignore +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.gitmessage +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.python-version +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/LICENSE +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/README.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/SKILLS.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/Makefile +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/make.bat +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/export.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/entity.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/namespaces/stats.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/view.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/polars_utils.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/spatial.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: core-lens
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.dev135
|
|
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
|
|
@@ -34,6 +34,7 @@ Requires-Dist: matplotlib<4,>=3.10.9; extra == 'full'
|
|
|
34
34
|
Requires-Dist: polars<2,>=1.39.0; extra == 'full'
|
|
35
35
|
Requires-Dist: pyarrow<25,>=23.0.0; extra == 'full'
|
|
36
36
|
Requires-Dist: pydantic<3,>=2.13.3; extra == 'full'
|
|
37
|
+
Requires-Dist: pyogrio<1,>=0.10.0; extra == 'full'
|
|
37
38
|
Requires-Dist: pyproj<4,>=3.7.2; extra == 'full'
|
|
38
39
|
Requires-Dist: scipy<2,>=1.17.1; extra == 'full'
|
|
39
40
|
Requires-Dist: shapely<3,>=2.1.2; extra == 'full'
|
|
@@ -52,6 +53,7 @@ Requires-Dist: matplotlib<4,>=3.10.9; extra == 'full-gpu'
|
|
|
52
53
|
Requires-Dist: polars<2,>=1.39.0; extra == 'full-gpu'
|
|
53
54
|
Requires-Dist: pyarrow<25,>=23.0.0; extra == 'full-gpu'
|
|
54
55
|
Requires-Dist: pydantic<3,>=2.13.3; extra == 'full-gpu'
|
|
56
|
+
Requires-Dist: pyogrio<1,>=0.10.0; extra == 'full-gpu'
|
|
55
57
|
Requires-Dist: pyproj<4,>=3.7.2; extra == 'full-gpu'
|
|
56
58
|
Requires-Dist: scipy<2,>=1.17.1; extra == 'full-gpu'
|
|
57
59
|
Requires-Dist: shapely<3,>=2.1.2; extra == 'full-gpu'
|
|
@@ -72,6 +74,7 @@ Requires-Dist: loguru>=0.7.3; extra == 'spatial'
|
|
|
72
74
|
Requires-Dist: polars<2,>=1.39.0; extra == 'spatial'
|
|
73
75
|
Requires-Dist: pyarrow<25,>=23.0.0; extra == 'spatial'
|
|
74
76
|
Requires-Dist: pydantic<3,>=2.13.3; extra == 'spatial'
|
|
77
|
+
Requires-Dist: pyogrio<1,>=0.10.0; extra == 'spatial'
|
|
75
78
|
Requires-Dist: pyproj<4,>=3.7.2; extra == 'spatial'
|
|
76
79
|
Requires-Dist: shapely<3,>=2.1.2; extra == 'spatial'
|
|
77
80
|
Provides-Extra: stats
|
|
@@ -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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, '
|
|
21
|
+
__version__ = version = '0.1.dev135'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 'dev135')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -176,6 +176,7 @@ class AoI:
|
|
|
176
176
|
geometry: "shapely.Geometry | None" = None,
|
|
177
177
|
seasons: SeasonConfig | None = None,
|
|
178
178
|
storage_options: dict[str, Any] | None = None,
|
|
179
|
+
validate_all: bool = False,
|
|
179
180
|
**entity_kwargs: str | list[str],
|
|
180
181
|
) -> None:
|
|
181
182
|
"""Resolve the AoI boundary and scope all registered entities.
|
|
@@ -195,6 +196,13 @@ class AoI:
|
|
|
195
196
|
calls. For S3 the common keys are ``"region"``,
|
|
196
197
|
``"access_key"``, and ``"secret_key"``. ``None`` (default)
|
|
197
198
|
uses ambient credentials (env-vars / ``~/.aws/``).
|
|
199
|
+
validate_all (bool, optional): When ``True``, eagerly instantiate
|
|
200
|
+
and validate every registered entity at construction time,
|
|
201
|
+
preserving the original fail-fast behaviour. When ``False``
|
|
202
|
+
(default), entities are built and validated lazily on first
|
|
203
|
+
access — a broken entity only surfaces an error when it is
|
|
204
|
+
actually used. Use ``True`` in startup health-checks or
|
|
205
|
+
long-running services where early failure is preferable.
|
|
198
206
|
**entity_kwargs (str | list[str]): Named filter pairs that identify the boundary,
|
|
199
207
|
e.g. ``tehsil="Pangi"``, ``district="Chamba"``,
|
|
200
208
|
``state="Himachal Pradesh"``, ``mws_id="13_551"``.
|
|
@@ -237,15 +245,15 @@ class AoI:
|
|
|
237
245
|
"Provide exactly one boundary mode."
|
|
238
246
|
)
|
|
239
247
|
|
|
240
|
-
#
|
|
248
|
+
# Entity instances are created lazily on first access via _get_entity().
|
|
241
249
|
self._entity_instances: dict[str, BaseEntity] = {}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
250
|
+
|
|
251
|
+
# Initialise boundary-entity cache. Only populated by the
|
|
252
|
+
# named-boundary path (_resolve_named_boundary); bbox= / geometry=
|
|
253
|
+
# construction leaves these as None so __getattr__ falls through
|
|
254
|
+
# to the normal spatial_filter path for every entity.
|
|
255
|
+
self._boundary_entity_name: str | None = None
|
|
256
|
+
self._boundary_keys: pl.DataFrame | None = None
|
|
249
257
|
|
|
250
258
|
if geometry is not None:
|
|
251
259
|
self.geometry: "shapely.Geometry" = geometry
|
|
@@ -257,6 +265,12 @@ class AoI:
|
|
|
257
265
|
# Entity views are created lazily on demand in __getattr__.
|
|
258
266
|
self._scoped: dict[str, "View"] = {}
|
|
259
267
|
|
|
268
|
+
# Opt-in eager validation: instantiate + validate every registered
|
|
269
|
+
# entity now so that a bad entity fails immediately rather than on
|
|
270
|
+
# first access. Useful for startup health-checks.
|
|
271
|
+
if validate_all:
|
|
272
|
+
self.validate()
|
|
273
|
+
|
|
260
274
|
@property
|
|
261
275
|
def current_season(self) -> str:
|
|
262
276
|
"""The season name for today's date under the AoI's SeasonConfig.
|
|
@@ -326,13 +340,79 @@ class AoI:
|
|
|
326
340
|
|
|
327
341
|
return lonboard.Map(layers=layers)
|
|
328
342
|
|
|
343
|
+
def _get_entity(self, name: str) -> BaseEntity:
|
|
344
|
+
"""Instantiate, validate, and cache an entity on first access.
|
|
345
|
+
|
|
346
|
+
Subsequent calls for the same *name* return the cached instance.
|
|
347
|
+
Validation delegates to :func:`_validate_entity` which routes
|
|
348
|
+
through the process-level :func:`_cached_detect` cache, so
|
|
349
|
+
repeated ``AoI()`` calls against the same ``data_root`` pay no
|
|
350
|
+
redundant Parquet footer reads.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
name (str): The registered entity name (e.g. ``"mws"``).
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
BaseEntity: The validated entity instance.
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
KeyError: If *name* is not in :data:`_REGISTRY`.
|
|
360
|
+
:class:`~core_lens.base.EntityValidationError`: If validation fails.
|
|
361
|
+
"""
|
|
362
|
+
if name not in self._entity_instances:
|
|
363
|
+
entity_cls = _REGISTRY[name]
|
|
364
|
+
entity = entity_cls(
|
|
365
|
+
data_root=self.data_root,
|
|
366
|
+
storage_options=self._storage_options or None,
|
|
367
|
+
)
|
|
368
|
+
_validate_entity(entity, name)
|
|
369
|
+
self._entity_instances[name] = entity
|
|
370
|
+
return self._entity_instances[name]
|
|
371
|
+
|
|
372
|
+
def validate(self) -> None:
|
|
373
|
+
"""Eagerly validate every registered entity.
|
|
374
|
+
|
|
375
|
+
Instantiates and validates all entities in :data:`_REGISTRY` that
|
|
376
|
+
have not yet been accessed. Raises on the first failure.
|
|
377
|
+
|
|
378
|
+
This is equivalent to the old eager-validation behaviour and is
|
|
379
|
+
useful for startup health-checks in long-running services::
|
|
380
|
+
|
|
381
|
+
aoi = AoI("data/", bbox=(...), validate_all=True)
|
|
382
|
+
# or:
|
|
383
|
+
aoi = AoI("data/", bbox=(...))
|
|
384
|
+
aoi.validate() # explicit call, same effect
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
:class:`~core_lens.base.EntityValidationError`: If any registered
|
|
388
|
+
entity fails validation.
|
|
389
|
+
"""
|
|
390
|
+
for name in _REGISTRY:
|
|
391
|
+
self._get_entity(name)
|
|
392
|
+
|
|
329
393
|
def __getattr__(self, name: str) -> "View":
|
|
330
394
|
# Called only when normal attribute lookup has already failed, so this
|
|
331
395
|
# never shadows real attributes. Maps entity names to their scoped Views.
|
|
332
396
|
if name in _REGISTRY:
|
|
333
397
|
if name not in self._scoped:
|
|
334
|
-
entity = self.
|
|
335
|
-
|
|
398
|
+
entity = self._get_entity(name)
|
|
399
|
+
if name == self._boundary_entity_name:
|
|
400
|
+
# This entity *defined* the AoI boundary — we already
|
|
401
|
+
# have its exact matching keys from
|
|
402
|
+
# _resolve_named_boundary. Skip the bbox-index build
|
|
403
|
+
# and geometric predicate entirely.
|
|
404
|
+
from core_lens.base.view import View
|
|
405
|
+
|
|
406
|
+
assert (
|
|
407
|
+
self._boundary_keys is not None
|
|
408
|
+
) # set by _resolve_named_boundary
|
|
409
|
+
view = View(
|
|
410
|
+
keys=self._boundary_keys,
|
|
411
|
+
entity=entity,
|
|
412
|
+
entity_name=name,
|
|
413
|
+
)
|
|
414
|
+
else:
|
|
415
|
+
view = entity.spatial_filter(geometry=self.geometry)
|
|
336
416
|
view._season_config = self.seasons
|
|
337
417
|
self._scoped[name] = view
|
|
338
418
|
return self._scoped[name]
|
|
@@ -366,9 +446,11 @@ class AoI:
|
|
|
366
446
|
logger.debug("Resolving named boundary using kwargs: {}", entity_kwargs)
|
|
367
447
|
|
|
368
448
|
# Find the registered entity whose key_col or known attribute column
|
|
369
|
-
# matches one of the filter keys.
|
|
449
|
+
# matches one of the filter keys. Entities are lazily instantiated
|
|
450
|
+
# via _get_entity() — only those inspected during the search are built.
|
|
370
451
|
candidate: BaseEntity | None = None
|
|
371
|
-
for name
|
|
452
|
+
for name in _REGISTRY:
|
|
453
|
+
entity = self._get_entity(name)
|
|
372
454
|
schema = entity.schema_profile
|
|
373
455
|
if any(
|
|
374
456
|
k in schema.key_cols or k in schema.extra_static_cols
|
|
@@ -383,9 +465,9 @@ class AoI:
|
|
|
383
465
|
logger.debug(
|
|
384
466
|
"No direct column match found for kwargs, attempting entity name fallback"
|
|
385
467
|
)
|
|
386
|
-
for name
|
|
468
|
+
for name in _REGISTRY:
|
|
387
469
|
if name in entity_kwargs:
|
|
388
|
-
candidate =
|
|
470
|
+
candidate = self._get_entity(name)
|
|
389
471
|
break
|
|
390
472
|
|
|
391
473
|
if candidate is None:
|
|
@@ -418,6 +500,13 @@ class AoI:
|
|
|
418
500
|
|
|
419
501
|
df = lf.filter(filter_expr).select(candidate.key_cols + [geom_col]).collect()
|
|
420
502
|
|
|
503
|
+
# Cache the boundary-defining entity's name and its exact key rows so
|
|
504
|
+
# that __getattr__ can short-circuit the spatial_filter for this one
|
|
505
|
+
# entity — the keys are already known, no need to rebuild the bbox
|
|
506
|
+
# index or re-run a geometric predicate.
|
|
507
|
+
self._boundary_entity_name = _entity_name(type(candidate))
|
|
508
|
+
self._boundary_keys = df.select(candidate.key_cols)
|
|
509
|
+
|
|
421
510
|
if df.is_empty():
|
|
422
511
|
logger.error(
|
|
423
512
|
"Boundary resolution failed: no rows matched filters {}", entity_kwargs
|
|
@@ -506,6 +595,22 @@ def _bbox_to_polygon(
|
|
|
506
595
|
|
|
507
596
|
|
|
508
597
|
def _validate_entity(entity: BaseEntity, name: str) -> None:
|
|
598
|
+
"""Validate an entity's paths and schema.
|
|
599
|
+
|
|
600
|
+
File-existence checks are performed eagerly for local paths. Schema
|
|
601
|
+
validation (key columns, geometry column) is delegated to
|
|
602
|
+
:attr:`BaseEntity.schema_profile`, which routes through the process-level
|
|
603
|
+
:func:`_cached_detect` cache — so the second ``AoI()`` pointed at the
|
|
604
|
+
same ``data_root`` pays zero Parquet footer reads for validation.
|
|
605
|
+
|
|
606
|
+
Args:
|
|
607
|
+
entity (BaseEntity): The entity instance to validate.
|
|
608
|
+
name (str): Human-readable entity name for error messages.
|
|
609
|
+
|
|
610
|
+
Raises:
|
|
611
|
+
EntityValidationError: If any validation check fails.
|
|
612
|
+
"""
|
|
613
|
+
# --- Static path existence check ----------------------------------------
|
|
509
614
|
try:
|
|
510
615
|
static = entity._resolve(entity.static_path)
|
|
511
616
|
except FileNotFoundError:
|
|
@@ -531,13 +636,14 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
|
|
|
531
636
|
f"Entity {name!r}: static_path {static!r} does not exist."
|
|
532
637
|
)
|
|
533
638
|
|
|
534
|
-
|
|
639
|
+
# --- Schema validation (key_cols, geometry_col, geometry_type) -----------
|
|
640
|
+
# Delegate to schema_profile which routes through _cached_detect.
|
|
641
|
+
# detect() internally calls _require_cols for key_cols and geometry_col,
|
|
642
|
+
# and infers geometry_type — so this covers the same checks that the old
|
|
643
|
+
# _validate_entity did with a raw pl.scan_parquet().collect_schema(), but
|
|
644
|
+
# without a redundant footer read.
|
|
535
645
|
try:
|
|
536
|
-
|
|
537
|
-
static,
|
|
538
|
-
hive_partitioning=True,
|
|
539
|
-
storage_options=storage_opts or None,
|
|
540
|
-
).collect_schema()
|
|
646
|
+
_ = entity.schema_profile
|
|
541
647
|
except Exception as exc:
|
|
542
648
|
logger.error(
|
|
543
649
|
"Validation failed for entity {}: could not read schema from '{}': {}",
|
|
@@ -549,29 +655,7 @@ def _validate_entity(entity: BaseEntity, name: str) -> None:
|
|
|
549
655
|
f"Entity {name!r}: could not read schema from {static!r}: {exc}"
|
|
550
656
|
) from exc
|
|
551
657
|
|
|
552
|
-
|
|
553
|
-
if missing_keys:
|
|
554
|
-
logger.error(
|
|
555
|
-
"Validation failed for entity {}: key columns {} not found in schema.",
|
|
556
|
-
name,
|
|
557
|
-
missing_keys,
|
|
558
|
-
)
|
|
559
|
-
raise EntityValidationError(
|
|
560
|
-
f"Entity {name!r}: key_cols {missing_keys} not found in {static!r}. "
|
|
561
|
-
f"Available columns: {list(schema.keys())}."
|
|
562
|
-
)
|
|
563
|
-
|
|
564
|
-
if entity.geometry_col not in schema:
|
|
565
|
-
logger.error(
|
|
566
|
-
"Validation failed for entity {}: geometry column '{}' not found in schema.",
|
|
567
|
-
name,
|
|
568
|
-
entity.geometry_col,
|
|
569
|
-
)
|
|
570
|
-
raise EntityValidationError(
|
|
571
|
-
f"Entity {name!r}: geometry_col {entity.geometry_col!r} "
|
|
572
|
-
f"not found in {static!r}. Available columns: {list(schema.keys())}."
|
|
573
|
-
)
|
|
574
|
-
|
|
658
|
+
# --- Temporal path existence checks -------------------------------------
|
|
575
659
|
for attr, label in [("annual_path", "annual"), ("fortnightly_path", "fortnightly")]:
|
|
576
660
|
path = getattr(entity, attr)
|
|
577
661
|
if path is not None:
|
|
@@ -187,20 +187,50 @@ def geojson(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> None:
|
|
|
187
187
|
"Call .with_geometry() first to join the static geometry column before exporting to geojson."
|
|
188
188
|
)
|
|
189
189
|
|
|
190
|
-
|
|
190
|
+
geom_col = result.entity.geometry_col
|
|
191
|
+
data = result.data
|
|
192
|
+
|
|
193
|
+
driver = kwargs.pop("driver", "GeoJSON")
|
|
194
|
+
is_seq = driver == "GeoJSONSeq"
|
|
195
|
+
|
|
196
|
+
if kwargs:
|
|
197
|
+
from loguru import logger
|
|
198
|
+
|
|
199
|
+
logger.warning(
|
|
200
|
+
"geojson() now uses a custom streaming Polars writer. "
|
|
201
|
+
"Driver-specific kwargs ({}) are ignored.",
|
|
202
|
+
list(kwargs.keys()),
|
|
203
|
+
)
|
|
191
204
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
engine = kwargs.pop("engine", None)
|
|
195
|
-
if engine is None:
|
|
196
|
-
try:
|
|
197
|
-
import pyogrio as _ # type: ignore[import-untyped] # noqa: F401
|
|
205
|
+
import shapely
|
|
206
|
+
import tempfile
|
|
198
207
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
208
|
+
wkb_arr = data[geom_col].to_numpy()
|
|
209
|
+
geoms = shapely.from_wkb(wkb_arr)
|
|
210
|
+
geom_strs = shapely.to_geojson(geoms)
|
|
202
211
|
|
|
203
|
-
|
|
204
|
-
|
|
212
|
+
with tempfile.NamedTemporaryFile(mode="w+") as tmp:
|
|
213
|
+
data.drop(geom_col).write_ndjson(tmp.name)
|
|
214
|
+
tmp.seek(0)
|
|
205
215
|
|
|
206
|
-
|
|
216
|
+
with open(path, "w") as out:
|
|
217
|
+
if not is_seq:
|
|
218
|
+
out.write('{"type": "FeatureCollection", "features": [\n')
|
|
219
|
+
|
|
220
|
+
first = True
|
|
221
|
+
for i, prop_json_str in enumerate(tmp):
|
|
222
|
+
if not first:
|
|
223
|
+
out.write("\n" if is_seq else ",\n")
|
|
224
|
+
else:
|
|
225
|
+
first = False
|
|
226
|
+
|
|
227
|
+
# construct the Feature JSON manually to avoid parsing/re-serialising
|
|
228
|
+
out.write(
|
|
229
|
+
f'{{"type": "Feature", "geometry": {geom_strs[i]}, '
|
|
230
|
+
f'"properties": {prop_json_str.strip()}}}'
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if not is_seq:
|
|
234
|
+
out.write("\n]}\n")
|
|
235
|
+
else:
|
|
236
|
+
out.write("\n")
|
|
@@ -12,7 +12,6 @@ import shapely.wkb as swkb
|
|
|
12
12
|
|
|
13
13
|
from core_lens.aoi import AoI, SeasonConfig, _REGISTRY, _entity_name
|
|
14
14
|
from core_lens.base.entity import BaseEntity, EntityValidationError
|
|
15
|
-
from core_lens.schema.profile import SchemaProfile
|
|
16
15
|
|
|
17
16
|
|
|
18
17
|
def _write_parquet_no_key_col(path: pathlib.Path) -> None:
|
|
@@ -42,6 +41,10 @@ def _make_bad_entity(
|
|
|
42
41
|
) -> type[BaseEntity]:
|
|
43
42
|
"""Return a concrete entity class that deliberately mismatches the Parquet schema.
|
|
44
43
|
|
|
44
|
+
This helper does **not** override ``schema_profile``, so the detection
|
|
45
|
+
layer (``detect()``) runs against the actual file and raises on column
|
|
46
|
+
mismatches.
|
|
47
|
+
|
|
45
48
|
Args:
|
|
46
49
|
static: Path to an already-written Parquet file.
|
|
47
50
|
key_cols: key_cols value to declare on the entity (may not exist in file).
|
|
@@ -50,16 +53,7 @@ def _make_bad_entity(
|
|
|
50
53
|
Returns:
|
|
51
54
|
A ``BaseEntity`` subclass whose declared columns differ from the file.
|
|
52
55
|
"""
|
|
53
|
-
profile = SchemaProfile(
|
|
54
|
-
key_cols=key_cols,
|
|
55
|
-
geometry_col=geometry_col,
|
|
56
|
-
geometry_type="wkb",
|
|
57
|
-
annual_time_col=None,
|
|
58
|
-
fortnightly_time_col=None,
|
|
59
|
-
bbox_cols=None,
|
|
60
|
-
)
|
|
61
56
|
_static = str(static)
|
|
62
|
-
_profile = profile
|
|
63
57
|
_key_cols = key_cols
|
|
64
58
|
_geometry_col = geometry_col
|
|
65
59
|
|
|
@@ -76,10 +70,6 @@ def _make_bad_entity(
|
|
|
76
70
|
def static_path(self) -> str:
|
|
77
71
|
return _static
|
|
78
72
|
|
|
79
|
-
@property
|
|
80
|
-
def schema_profile(self) -> SchemaProfile:
|
|
81
|
-
return _profile
|
|
82
|
-
|
|
83
73
|
return _BadEntity
|
|
84
74
|
|
|
85
75
|
|
|
@@ -182,7 +172,7 @@ class TestAoIRegister:
|
|
|
182
172
|
_write_parquet_no_key_col(p)
|
|
183
173
|
cls = _make_bad_entity(p, key_cols=["mws_id"], geometry_col="geometry")
|
|
184
174
|
|
|
185
|
-
with pytest.raises(EntityValidationError, match="
|
|
175
|
+
with pytest.raises(EntityValidationError, match="could not read schema"):
|
|
186
176
|
AoI.register(cls)
|
|
187
177
|
|
|
188
178
|
def test_register_missing_geometry_col_raises(self, tmp_path: Any) -> None:
|
|
@@ -190,7 +180,7 @@ class TestAoIRegister:
|
|
|
190
180
|
_write_parquet_no_geom_col(p)
|
|
191
181
|
cls = _make_bad_entity(p, key_cols=["mws_id"], geometry_col="geometry")
|
|
192
182
|
|
|
193
|
-
with pytest.raises(EntityValidationError, match="
|
|
183
|
+
with pytest.raises(EntityValidationError, match="could not read schema"):
|
|
194
184
|
AoI.register(cls)
|
|
195
185
|
|
|
196
186
|
def test_register_missing_annual_path_raises(
|