core-lens 0.1.dev130__tar.gz → 0.1.dev133__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.dev130 → core_lens-0.1.dev133}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/aoi.py +25 -2
  4. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/entity.py +128 -15
  5. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/view.py +14 -24
  6. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/schema/detection.py +36 -0
  7. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/schema/profile.py +6 -0
  8. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/utils/spatial.py +28 -17
  9. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  10. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  11. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  12. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/pull_request_template.md +0 -0
  13. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/workflows/ci.yml +0 -0
  14. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/workflows/gh-pages.yml +0 -0
  15. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/workflows/pre-release.yml +0 -0
  16. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.github/workflows/release.yml +0 -0
  17. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.gitignore +0 -0
  18. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.gitmessage +0 -0
  19. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.pre-commit-config.yaml +0 -0
  20. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/.python-version +0 -0
  21. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/CONTRIBUTING.md +0 -0
  22. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/LICENSE +0 -0
  23. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/README.md +0 -0
  24. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/SKILLS.md +0 -0
  25. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/README.md +0 -0
  26. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_aoi.py +0 -0
  27. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_export.py +0 -0
  28. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_polars_utils.py +0 -0
  29. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_result.py +0 -0
  30. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_schema.py +0 -0
  31. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_season.py +0 -0
  32. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_spatial.py +0 -0
  33. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/bench_view.py +0 -0
  34. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/benchmarks/run_all.sh +0 -0
  35. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/Makefile +0 -0
  36. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/make.bat +0 -0
  37. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/concepts.md +0 -0
  38. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/conf.py +0 -0
  39. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/export.md +0 -0
  40. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/index.rst +0 -0
  41. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/intro.md +0 -0
  42. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/logging.md +0 -0
  43. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/plots.md +0 -0
  44. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/plugins.md +0 -0
  45. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/queries.md +0 -0
  46. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/quickstart.md +0 -0
  47. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/docs/source/stats.md +0 -0
  48. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/examples/demo_mws.py +0 -0
  49. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/examples/demo_tehsil.py +0 -0
  50. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/hooks/mypy.sh +0 -0
  51. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/hooks/no-parquet-outside-fixtures.sh +0 -0
  52. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/hooks/pytest.sh +0 -0
  53. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/pyproject.toml +0 -0
  54. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/__init__.py +0 -0
  55. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/__main__.py +0 -0
  56. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/__init__.py +0 -0
  57. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/namespaces/__init__.py +0 -0
  58. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/namespaces/plot.py +0 -0
  59. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/namespaces/stats.py +0 -0
  60. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/base/result.py +0 -0
  61. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/entities/__init__.py +0 -0
  62. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/entities/mws.py +0 -0
  63. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/entities/tehsil.py +0 -0
  64. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/entities/waterbody.py +0 -0
  65. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/export/__init__.py +0 -0
  66. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/export/formats.py +0 -0
  67. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/py.typed +0 -0
  68. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/schema/__init__.py +0 -0
  69. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/utils/__init__.py +0 -0
  70. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/utils/paths.py +0 -0
  71. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/utils/polars_utils.py +0 -0
  72. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/src/core_lens/utils/season.py +0 -0
  73. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/fixtures/generate_fixtures.py +0 -0
  74. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/conftest.py +0 -0
  75. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_aoi.py +0 -0
  76. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_entities.py +0 -0
  77. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_entity.py +0 -0
  78. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_export.py +0 -0
  79. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_main.py +0 -0
  80. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_plot.py +0 -0
  81. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_polars_utils.py +0 -0
  82. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_profile.py +0 -0
  83. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_result.py +0 -0
  84. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_schema_detection.py +0 -0
  85. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_schema_profile.py +0 -0
  86. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_season.py +0 -0
  87. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_season_config.py +0 -0
  88. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_spatial.py +0 -0
  89. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_stats.py +0 -0
  90. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/tests/unit/test_view.py +0 -0
  91. {core_lens-0.1.dev130 → core_lens-0.1.dev133}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-lens
3
- Version: 0.1.dev130
3
+ Version: 0.1.dev133
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
@@ -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.dev130'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev130')
21
+ __version__ = version = '0.1.dev133'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev133')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
22
22
  from core_lens.base.view import View
23
23
 
24
24
 
25
- @dataclass
25
+ @dataclass(frozen=True)
26
26
  class SeasonConfig:
27
27
  """Date-range definitions for the three Indian crop seasons.
28
28
 
@@ -96,6 +96,29 @@ def _md_in_range(md: str, start: str, end: str) -> bool:
96
96
  return md >= start or md <= end
97
97
 
98
98
 
99
+ # Singleton for the default SeasonConfig — constructed once per process.
100
+ # SeasonConfig.__post_init__ runs 6× datetime.strptime calls for validation;
101
+ # sharing the default instance eliminates that cost for every AoI() call
102
+ # that does not supply a custom season config.
103
+ _DEFAULT_SEASON_CONFIG: SeasonConfig | None = None
104
+
105
+
106
+ def _default_season_config() -> SeasonConfig:
107
+ """Return the shared default :class:`SeasonConfig` instance.
108
+
109
+ Constructed once per process and reused thereafter. Callers that need
110
+ a custom config should construct their own :class:`SeasonConfig` and
111
+ pass it to :class:`AoI`.
112
+
113
+ Returns:
114
+ SeasonConfig: The shared default instance.
115
+ """
116
+ global _DEFAULT_SEASON_CONFIG
117
+ if _DEFAULT_SEASON_CONFIG is None:
118
+ _DEFAULT_SEASON_CONFIG = SeasonConfig()
119
+ return _DEFAULT_SEASON_CONFIG
120
+
121
+
99
122
  # Class-level registry: entity name → entity **class**.
100
123
  # Shared across all AoI instances in a process. Explicit registration is
101
124
  # required; there is no auto-discovery (design §6.1).
@@ -191,7 +214,7 @@ class AoI:
191
214
  self.data_root = pathlib.Path(data_root_str).resolve()
192
215
 
193
216
  self._storage_options: dict[str, Any] = storage_options or {}
194
- self.seasons: SeasonConfig = seasons or SeasonConfig()
217
+ self.seasons: SeasonConfig = seasons or _default_season_config()
195
218
  logger.info("Initializing AoI with data_root={}", self.data_root)
196
219
 
197
220
  n_modes = sum(
@@ -11,6 +11,7 @@ Plugin authors import from the public surface::
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ import functools
14
15
  import pathlib
15
16
  from abc import ABC, abstractmethod
16
17
  from typing import TYPE_CHECKING, Any
@@ -35,6 +36,96 @@ if TYPE_CHECKING:
35
36
  from core_lens.schema.profile import SchemaProfile
36
37
 
37
38
 
39
+ # ---------------------------------------------------------------------------
40
+ # Process-level caches — shared across all BaseEntity instances in a process.
41
+ #
42
+ # Both caches are backed by functools.cache (lru_cache with maxsize=None).
43
+ # maxsize=None means unbounded — intentional: evicting an index or schema
44
+ # profile forces an expensive rebuild on the next access.
45
+ #
46
+ # _cached_detect eliminates repeated detect() calls (OPT-2).
47
+ # _cached_build_index eliminates repeated build_bbox_index() calls (OPT-1).
48
+ #
49
+ # Cache diagnostics: _cached_detect.cache_info() / _cached_build_index.cache_info()
50
+ # Cache reset (tests): _cached_detect.cache_clear() / _cached_build_index.cache_clear()
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ def _so_key(storage_options: dict[str, Any]) -> tuple[tuple[str, Any], ...]:
55
+ """Convert a ``storage_options`` dict to a hashable, order-stable tuple.
56
+
57
+ All args passed to :func:`_cached_detect` and :func:`_cached_build_index`
58
+ must be hashable (``functools.cache`` requirement). ``storage_options``
59
+ is a plain dict, so we convert it here before passing to the cached
60
+ functions. Different cloud credentials for the same path produce
61
+ different cache keys.
62
+
63
+ Args:
64
+ storage_options (dict[str, Any]): Cloud credential / configuration dict.
65
+
66
+ Returns:
67
+ tuple[tuple[str, Any], ...]: A sorted tuple of ``(key, value)`` pairs.
68
+ """
69
+ return tuple(sorted(storage_options.items()))
70
+
71
+
72
+ @functools.cache
73
+ def _cached_detect(
74
+ static_path: str,
75
+ key_cols: tuple[str, ...],
76
+ geometry_col: str,
77
+ annual_path: str | None,
78
+ fortnightly_path: str | None,
79
+ storage_options_key: tuple[tuple[str, Any], ...],
80
+ ) -> "SchemaProfile":
81
+ """Cached wrapper around :func:`~core_lens.schema.detection.detect`.
82
+
83
+ All arguments must be hashable so they can form the cache key. Pass
84
+ ``storage_options`` through :func:`_so_key` before calling this function.
85
+
86
+ Returns:
87
+ SchemaProfile: Detected schema profile for the given entity paths.
88
+ """
89
+ from core_lens.schema.detection import detect
90
+
91
+ return detect(
92
+ static_path=static_path,
93
+ key_cols=list(key_cols),
94
+ geometry_col=geometry_col,
95
+ annual_path=annual_path,
96
+ fortnightly_path=fortnightly_path,
97
+ storage_options=dict(storage_options_key) or None,
98
+ )
99
+
100
+
101
+ @functools.cache
102
+ def _cached_build_index(
103
+ static_path: str,
104
+ key_cols: tuple[str, ...],
105
+ bbox_cols: tuple[str, str, str, str] | None,
106
+ geometry_col: str,
107
+ geometry_type: str,
108
+ storage_options_key: tuple[tuple[str, Any], ...],
109
+ ) -> pl.DataFrame:
110
+ """Cached wrapper around :func:`~core_lens.utils.spatial.build_bbox_index`.
111
+
112
+ All arguments must be hashable so they can form the cache key. Pass
113
+ ``storage_options`` through :func:`_so_key` before calling this function.
114
+
115
+ Returns:
116
+ pl.DataFrame: The bounding-box index DataFrame.
117
+ """
118
+ logger.debug("Building bbox index for {} (cache miss)", static_path)
119
+ return build_bbox_index(
120
+ static_path=static_path,
121
+ key_cols=list(key_cols),
122
+ bbox_cols=bbox_cols,
123
+ geometry_col=geometry_col,
124
+ geometry_type=geometry_type,
125
+ storage_options=dict(storage_options_key) or None,
126
+ )
127
+
128
+
38
129
  class BaseEntity(ABC):
39
130
  """Abstract base class for every entity in the core_lens plugin system.
40
131
 
@@ -228,19 +319,29 @@ class BaseEntity(ABC):
228
319
  def schema_profile(self) -> "SchemaProfile":
229
320
  """Validated schema descriptor for this entity's data files.
230
321
 
231
- Auto-detected from Parquet file metadata on first access and cached on
232
- the instance. Override in subclasses to provide an explicit profile
233
- instead of relying on detection.
322
+ Auto-detected from Parquet file metadata on first access. The result
323
+ is cached at two levels:
324
+
325
+ 1. **Instance level** — ``_schema_profile`` attribute set on ``self``
326
+ so that repeated property access within the same instance is a bare
327
+ attribute lookup with no dict overhead.
328
+ 2. **Process level** — :func:`_cached_detect` (backed by
329
+ ``functools.cache``) keyed on the resolved paths and entity
330
+ configuration. Different ``AoI()`` calls that point at the same
331
+ data directory share one ``SchemaProfile`` object, avoiding
332
+ redundant ``collect_schema()`` I/O.
333
+
334
+ Override in subclasses to provide an explicit profile instead of
335
+ relying on detection.
234
336
 
235
337
  Returns:
236
338
  SchemaProfile: A fully-validated :class:`~core_lens.schema.profile.SchemaProfile`.
237
339
  """
238
340
  if not hasattr(self, "_schema_profile"):
239
- from core_lens.schema.detection import detect
240
-
241
- self._schema_profile: SchemaProfile = detect(
341
+ _so = self._storage_options or {}
342
+ self._schema_profile: SchemaProfile = _cached_detect(
242
343
  static_path=self._resolve(self.static_path),
243
- key_cols=self.key_cols,
344
+ key_cols=tuple(self.key_cols),
244
345
  geometry_col=self.geometry_col,
245
346
  annual_path=(
246
347
  self._resolve(self.annual_path)
@@ -252,24 +353,36 @@ class BaseEntity(ABC):
252
353
  if self.fortnightly_path is not None
253
354
  else None
254
355
  ),
255
- storage_options=self._storage_options or None,
356
+ storage_options_key=_so_key(_so),
256
357
  )
257
358
  return self._schema_profile
258
359
 
259
360
  @property
260
361
  def _index(self) -> pl.DataFrame:
362
+ """In-memory bounding-box index for spatial pre-filtering.
363
+
364
+ Cached at two levels (same strategy as :attr:`schema_profile`):
365
+
366
+ 1. **Instance level** — ``_cached_index`` attribute on ``self``.
367
+ 2. **Process level** — :func:`_cached_build_index` (backed by
368
+ ``functools.cache``) keyed on the resolved static path, key
369
+ columns, and bbox/geometry schema. All entity instances that
370
+ resolve to the same static file share one index ``pl.DataFrame``,
371
+ eliminating repeated geometry decodes.
372
+
373
+ Returns:
374
+ pl.DataFrame: DataFrame with columns ``(*key_cols, minx, miny, maxx, maxy)``.
375
+ """
261
376
  if not hasattr(self, "_cached_index"):
262
- logger.debug(
263
- "Building lazy bounding box index for {}", self.__class__.__name__
264
- )
265
- profile = self.schema_profile
266
- self._cached_index: pl.DataFrame = build_bbox_index(
377
+ profile = self.schema_profile # already cached — no extra I/O
378
+ _so = self._storage_options or {}
379
+ self._cached_index: pl.DataFrame = _cached_build_index(
267
380
  static_path=self._resolve(self.static_path),
268
- key_cols=self.key_cols,
381
+ key_cols=tuple(self.key_cols),
269
382
  bbox_cols=profile.bbox_cols,
270
383
  geometry_col=profile.geometry_col,
271
384
  geometry_type=profile.geometry_type,
272
- storage_options=self._storage_options or None,
385
+ storage_options_key=_so_key(_so),
273
386
  )
274
387
  return self._cached_index
275
388
 
@@ -407,28 +407,18 @@ class View:
407
407
  if time_col is not None:
408
408
  from core_lens.utils.season import resolve_time_filter
409
409
 
410
- # Fall back to a no-op SeasonConfig if none was supplied on this View.
411
- from core_lens.aoi import SeasonConfig
412
-
413
- season_cfg = self._season_config or SeasonConfig()
414
-
415
- # Detect whether the time column is an integer year column so
416
- # _date_range_expr can branch at expression-build time (avoids
417
- # the cast-to-string + runtime when/then triple-copy).
418
- try:
419
- pq_schema = pl.scan_parquet(
420
- abs_path,
421
- storage_options=self._storage_options or None,
422
- ).collect_schema()
423
- _time_dtype = pq_schema.get(time_col)
424
- _is_year_col = _time_dtype is not None and _time_dtype in (
425
- pl.Int32,
426
- pl.Int64,
427
- pl.UInt32,
428
- pl.UInt16,
429
- )
430
- except Exception:
431
- _is_year_col = False
410
+ # Fall back to the default SeasonConfig if none was supplied on this View.
411
+ from core_lens.aoi import _default_season_config
412
+
413
+ season_cfg = self._season_config or _default_season_config()
414
+
415
+ # Read is_year_col from the cached schema profile populated at
416
+ # detect() time from the Parquet schema, so no extra I/O here.
417
+ _is_year_col: bool | None = (
418
+ profile.annual_is_year_col
419
+ if resolution == Resolution.ANNUAL
420
+ else profile.fortnightly_is_year_col
421
+ )
432
422
 
433
423
  # Pass as a private hint inside the filter dict (non-mutating copy).
434
424
  _tf = dict(self.time_filter)
@@ -454,9 +444,9 @@ class View:
454
444
  and profile.fortnightly_time_col is not None
455
445
  ):
456
446
  from core_lens.utils.season import add_temporal_columns
457
- from core_lens.aoi import SeasonConfig
447
+ from core_lens.aoi import _default_season_config
458
448
 
459
- season_cfg = self._season_config or SeasonConfig()
449
+ season_cfg = self._season_config or _default_season_config()
460
450
  data = add_temporal_columns(data, profile.fortnightly_time_col, season_cfg)
461
451
 
462
452
  if self.join_spec is not None:
@@ -104,6 +104,7 @@ def detect(
104
104
 
105
105
  annual_time_col: str | None = None
106
106
  extra_annual_cols: list[str] = []
107
+ annual_is_year_col: bool | None = None
107
108
  if annual_path:
108
109
  annual_schema = _read_schema(
109
110
  annual_path, label="annual", storage_options=_so or None
@@ -113,9 +114,11 @@ def detect(
113
114
  {annual_time_col} if annual_time_col else set()
114
115
  )
115
116
  extra_annual_cols = [c for c in annual_schema if c not in reserved_annual]
117
+ annual_is_year_col = _is_year_col_from_schema(annual_schema, annual_time_col)
116
118
 
117
119
  fortnightly_time_col: str | None = None
118
120
  extra_fortnightly_cols: list[str] = []
121
+ fortnightly_is_year_col: bool | None = None
119
122
  if fortnightly_path:
120
123
  fortnightly_schema = _read_schema(
121
124
  fortnightly_path, label="fortnightly", storage_options=_so or None
@@ -125,6 +128,9 @@ def detect(
125
128
  {fortnightly_time_col} if fortnightly_time_col else set()
126
129
  )
127
130
  extra_fortnightly_cols = [c for c in fortnightly_schema if c not in reserved_fn]
131
+ fortnightly_is_year_col = _is_year_col_from_schema(
132
+ fortnightly_schema, fortnightly_time_col
133
+ )
128
134
 
129
135
  return SchemaProfile(
130
136
  key_cols=key_cols,
@@ -136,6 +142,8 @@ def detect(
136
142
  extra_static_cols=extra_static_cols,
137
143
  extra_annual_cols=extra_annual_cols,
138
144
  extra_fortnightly_cols=extra_fortnightly_cols,
145
+ annual_is_year_col=annual_is_year_col,
146
+ fortnightly_is_year_col=fortnightly_is_year_col,
139
147
  )
140
148
 
141
149
 
@@ -237,3 +245,31 @@ def _infer_time_col(schema: pl.Schema, path: str) -> str | None:
237
245
  return col
238
246
 
239
247
  return None
248
+
249
+
250
+ _YEAR_COL_DTYPES = (pl.Int32, pl.Int64, pl.UInt32, pl.UInt16)
251
+
252
+
253
+ def _is_year_col_from_schema(
254
+ schema: pl.Schema,
255
+ time_col: str | None,
256
+ ) -> bool | None:
257
+ """Return whether *time_col* is an integer-year column, or ``None`` if absent.
258
+
259
+ ``True`` — integer dtype (``Int32`` / ``Int64`` / ``UInt32`` / ``UInt16``).
260
+ ``False`` — ``Date`` / ``Datetime`` column.
261
+ ``None`` — *time_col* is ``None`` or not found in *schema*.
262
+
263
+ Args:
264
+ schema (pl.Schema): The Parquet schema returned by ``collect_schema()``.
265
+ time_col (str | None): The name of the time column, or ``None``.
266
+
267
+ Returns:
268
+ bool | None: ``True`` for integer-year, ``False`` for Date/Datetime, ``None`` if absent.
269
+ """
270
+ if time_col is None:
271
+ return None
272
+ dtype = schema.get(time_col)
273
+ if dtype is None:
274
+ return None
275
+ return dtype in _YEAR_COL_DTYPES
@@ -69,6 +69,12 @@ class SchemaProfile(BaseModel):
69
69
  extra_static_cols: list[str] = []
70
70
  extra_annual_cols: list[str] = []
71
71
  extra_fortnightly_cols: list[str] = []
72
+ # Whether the time column is an integer-year column (True), a Date/Datetime
73
+ # column (False), or absent/unknown (None). Determined at detect() time from
74
+ # the Parquet schema — eliminates a redundant collect_schema() call in
75
+ # View._materialise for every .annual / .fortnightly access.
76
+ annual_is_year_col: bool | None = None
77
+ fortnightly_is_year_col: bool | None = None
72
78
 
73
79
  @classmethod
74
80
  def from_file(cls, path: str | pathlib.Path) -> "SchemaProfile":
@@ -247,28 +247,39 @@ def exact_spatial_filter(
247
247
  else:
248
248
  geoms = shapely.from_wkt(geom_array)
249
249
 
250
+ import numpy as np
251
+
250
252
  if relationship == "centroid":
251
253
  # Centroid mode: entity centroid must lie within the AoI geometry.
252
- # STRtree.query(aoi_geometry, predicate="contains") returns i where
253
- # aoi_geometry.contains(centroid[i]) i.e. centroid inside the AoI.
254
- test_geoms = [g.centroid for g in geoms]
255
- tree = shapely.STRtree(test_geoms)
256
- hit_indices = tree.query(aoi_geometry, predicate="contains").tolist()
254
+ # STRtree is overkill for a single-query containment test building
255
+ # the tree costs O(n log n) with no payoff when there is only one query
256
+ # geometry. Vectorised shapely ops stay in C and avoid the overhead.
257
+ centroids = shapely.centroid(geoms) # vectorised, C-level
258
+ mask = shapely.contains(aoi_geometry, centroids) # vectorised bool array
259
+ hit_indices = np.where(mask)[0].tolist()
257
260
 
258
261
  else: # area mode
259
- # Area mode: intersection area / entity area > threshold.
262
+ # Area mode: intersection area / entity area >= threshold.
263
+ # Vectorise: compute all areas and intersections in C via numpy arrays.
260
264
  tree = shapely.STRtree(geoms)
261
- # Candidate indices whose envelope intersects aoi_geometry.
262
- intersect_idx = tree.query(aoi_geometry, predicate="intersects").tolist()
263
- hit_indices = []
264
- for i in intersect_idx:
265
- entity_geom = geoms[i]
266
- entity_area = entity_geom.area # pyright: ignore[reportAttributeAccessIssue]
267
- if entity_area == 0.0:
268
- continue
269
- inter_area = entity_geom.intersection(aoi_geometry).area # pyright: ignore[reportAttributeAccessIssue]
270
- if inter_area / entity_area >= threshold:
271
- hit_indices.append(i)
265
+ intersect_idx = np.asarray(
266
+ tree.query(aoi_geometry, predicate="intersects"), dtype=np.intp
267
+ )
268
+ if len(intersect_idx) == 0:
269
+ hit_indices = []
270
+ else:
271
+ candidate_geoms = geoms[intersect_idx]
272
+ entity_areas = shapely.area(candidate_geoms) # vectorised
273
+ valid = entity_areas > 0
274
+ if not valid.any():
275
+ hit_indices = []
276
+ else:
277
+ inter_geoms = shapely.intersection(
278
+ candidate_geoms[valid], aoi_geometry
279
+ ) # vectorised
280
+ inter_areas = shapely.area(inter_geoms) # vectorised
281
+ ratios = inter_areas / entity_areas[valid]
282
+ hit_indices = intersect_idx[valid][ratios >= threshold].tolist()
272
283
 
273
284
  del geoms # free all decoded Shapely geometries before assembling result
274
285
 
File without changes
File without changes
File without changes
File without changes