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.
Files changed (91) hide show
  1. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/PKG-INFO +4 -1
  2. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/pyproject.toml +1 -0
  3. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/_version.py +2 -2
  4. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/aoi.py +127 -43
  5. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/export/formats.py +43 -13
  6. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_aoi.py +6 -16
  7. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/uv.lock +309 -272
  8. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  9. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  10. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  11. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/pull_request_template.md +0 -0
  12. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/ci.yml +0 -0
  13. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/gh-pages.yml +0 -0
  14. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/pre-release.yml +0 -0
  15. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.github/workflows/release.yml +0 -0
  16. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.gitignore +0 -0
  17. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.gitmessage +0 -0
  18. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.pre-commit-config.yaml +0 -0
  19. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/.python-version +0 -0
  20. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/CONTRIBUTING.md +0 -0
  21. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/LICENSE +0 -0
  22. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/README.md +0 -0
  23. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/SKILLS.md +0 -0
  24. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/README.md +0 -0
  25. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_aoi.py +0 -0
  26. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_export.py +0 -0
  27. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_polars_utils.py +0 -0
  28. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_result.py +0 -0
  29. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_schema.py +0 -0
  30. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_season.py +0 -0
  31. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_spatial.py +0 -0
  32. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/bench_view.py +0 -0
  33. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/benchmarks/run_all.sh +0 -0
  34. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/Makefile +0 -0
  35. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/make.bat +0 -0
  36. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/concepts.md +0 -0
  37. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/conf.py +0 -0
  38. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/export.md +0 -0
  39. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/index.rst +0 -0
  40. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/intro.md +0 -0
  41. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/logging.md +0 -0
  42. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/plots.md +0 -0
  43. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/plugins.md +0 -0
  44. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/queries.md +0 -0
  45. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/quickstart.md +0 -0
  46. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/docs/source/stats.md +0 -0
  47. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/examples/demo_mws.py +0 -0
  48. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/examples/demo_tehsil.py +0 -0
  49. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/hooks/mypy.sh +0 -0
  50. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/hooks/no-parquet-outside-fixtures.sh +0 -0
  51. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/hooks/pytest.sh +0 -0
  52. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/__init__.py +0 -0
  53. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/__main__.py +0 -0
  54. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/__init__.py +0 -0
  55. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/entity.py +0 -0
  56. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/namespaces/__init__.py +0 -0
  57. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/namespaces/plot.py +0 -0
  58. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/namespaces/stats.py +0 -0
  59. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/result.py +0 -0
  60. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/base/view.py +0 -0
  61. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/__init__.py +0 -0
  62. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/mws.py +0 -0
  63. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/tehsil.py +0 -0
  64. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/entities/waterbody.py +0 -0
  65. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/export/__init__.py +0 -0
  66. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/py.typed +0 -0
  67. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/schema/__init__.py +0 -0
  68. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/schema/detection.py +0 -0
  69. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/schema/profile.py +0 -0
  70. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/__init__.py +0 -0
  71. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/paths.py +0 -0
  72. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/polars_utils.py +0 -0
  73. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/season.py +0 -0
  74. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/src/core_lens/utils/spatial.py +0 -0
  75. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/fixtures/generate_fixtures.py +0 -0
  76. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/conftest.py +0 -0
  77. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_entities.py +0 -0
  78. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_entity.py +0 -0
  79. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_export.py +0 -0
  80. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_main.py +0 -0
  81. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_plot.py +0 -0
  82. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_polars_utils.py +0 -0
  83. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_profile.py +0 -0
  84. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_result.py +0 -0
  85. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_schema_detection.py +0 -0
  86. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_schema_profile.py +0 -0
  87. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_season.py +0 -0
  88. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_season_config.py +0 -0
  89. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_spatial.py +0 -0
  90. {core_lens-0.1.dev133 → core_lens-0.1.dev135}/tests/unit/test_stats.py +0 -0
  91. {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.dev133
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
@@ -57,6 +57,7 @@ spatial = [
57
57
  "geopandas>=1.1.3,<2",
58
58
  "shapely>=2.1.2,<3",
59
59
  "pyproj>=3.7.2,<4",
60
+ "pyogrio>=0.10.0,<1",
60
61
  ]
61
62
  viz = [
62
63
  "core-lens[core]",
@@ -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.dev133'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev133')
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
- # Instantiate and validate all registered entities with this data_root.
248
+ # Entity instances are created lazily on first access via _get_entity().
241
249
  self._entity_instances: dict[str, BaseEntity] = {}
242
- for name, entity_cls in _REGISTRY.items():
243
- entity = entity_cls(
244
- data_root=self.data_root,
245
- storage_options=self._storage_options or None,
246
- )
247
- _validate_entity(entity, name)
248
- self._entity_instances[name] = entity
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._entity_instances[name]
335
- view = entity.spatial_filter(geometry=self.geometry)
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, entity in self._entity_instances.items():
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, entity in self._entity_instances.items():
468
+ for name in _REGISTRY:
387
469
  if name in entity_kwargs:
388
- candidate = entity
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
- storage_opts = entity._storage_options or {}
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
- schema = pl.scan_parquet(
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
- missing_keys = [c for c in entity.key_cols if c not in schema]
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
- gdf = result.gdf()
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
- # Resolve write engine: prefer pyogrio (C-backed, no GDAL Python overhead),
193
- # fall back to fiona if not installed.
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
- engine = "pyogrio"
200
- except ModuleNotFoundError:
201
- engine = "fiona"
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
- # Always write as GeoJSON unless the caller overrides driver.
204
- kwargs.setdefault("driver", "GeoJSON")
212
+ with tempfile.NamedTemporaryFile(mode="w+") as tmp:
213
+ data.drop(geom_col).write_ndjson(tmp.name)
214
+ tmp.seek(0)
205
215
 
206
- gdf.to_file(path, engine=engine, **kwargs)
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="key_cols"):
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="geometry_col"):
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(