core-lens 0.1.dev129__tar.gz → 0.1.dev132__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.dev129 → core_lens-0.1.dev132}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/aoi.py +25 -2
  4. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/result.py +4 -2
  5. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/view.py +14 -24
  6. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/export/formats.py +20 -32
  7. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/schema/detection.py +36 -0
  8. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/schema/profile.py +6 -0
  9. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/utils/spatial.py +28 -17
  10. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_export.py +5 -2
  11. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  12. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  13. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  14. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/pull_request_template.md +0 -0
  15. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/workflows/ci.yml +0 -0
  16. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/workflows/gh-pages.yml +0 -0
  17. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/workflows/pre-release.yml +0 -0
  18. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.github/workflows/release.yml +0 -0
  19. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.gitignore +0 -0
  20. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.gitmessage +0 -0
  21. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.pre-commit-config.yaml +0 -0
  22. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/.python-version +0 -0
  23. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/CONTRIBUTING.md +0 -0
  24. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/LICENSE +0 -0
  25. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/README.md +0 -0
  26. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/SKILLS.md +0 -0
  27. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/README.md +0 -0
  28. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_aoi.py +0 -0
  29. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_export.py +0 -0
  30. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_polars_utils.py +0 -0
  31. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_result.py +0 -0
  32. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_schema.py +0 -0
  33. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_season.py +0 -0
  34. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_spatial.py +0 -0
  35. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/bench_view.py +0 -0
  36. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/benchmarks/run_all.sh +0 -0
  37. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/Makefile +0 -0
  38. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/make.bat +0 -0
  39. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/concepts.md +0 -0
  40. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/conf.py +0 -0
  41. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/export.md +0 -0
  42. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/index.rst +0 -0
  43. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/intro.md +0 -0
  44. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/logging.md +0 -0
  45. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/plots.md +0 -0
  46. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/plugins.md +0 -0
  47. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/queries.md +0 -0
  48. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/quickstart.md +0 -0
  49. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/docs/source/stats.md +0 -0
  50. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/examples/demo_mws.py +0 -0
  51. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/examples/demo_tehsil.py +0 -0
  52. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/hooks/mypy.sh +0 -0
  53. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/hooks/no-parquet-outside-fixtures.sh +0 -0
  54. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/hooks/pytest.sh +0 -0
  55. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/pyproject.toml +0 -0
  56. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/__init__.py +0 -0
  57. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/__main__.py +0 -0
  58. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/__init__.py +0 -0
  59. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/entity.py +0 -0
  60. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/namespaces/__init__.py +0 -0
  61. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/namespaces/plot.py +0 -0
  62. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/base/namespaces/stats.py +0 -0
  63. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/entities/__init__.py +0 -0
  64. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/entities/mws.py +0 -0
  65. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/entities/tehsil.py +0 -0
  66. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/entities/waterbody.py +0 -0
  67. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/export/__init__.py +0 -0
  68. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/py.typed +0 -0
  69. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/schema/__init__.py +0 -0
  70. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/utils/__init__.py +0 -0
  71. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/utils/paths.py +0 -0
  72. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/utils/polars_utils.py +0 -0
  73. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/src/core_lens/utils/season.py +0 -0
  74. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/fixtures/generate_fixtures.py +0 -0
  75. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/conftest.py +0 -0
  76. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_aoi.py +0 -0
  77. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_entities.py +0 -0
  78. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_entity.py +0 -0
  79. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_main.py +0 -0
  80. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_plot.py +0 -0
  81. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_polars_utils.py +0 -0
  82. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_profile.py +0 -0
  83. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_result.py +0 -0
  84. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_schema_detection.py +0 -0
  85. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_schema_profile.py +0 -0
  86. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_season.py +0 -0
  87. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_season_config.py +0 -0
  88. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_spatial.py +0 -0
  89. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_stats.py +0 -0
  90. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/tests/unit/test_view.py +0 -0
  91. {core_lens-0.1.dev129 → core_lens-0.1.dev132}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: core-lens
3
- Version: 0.1.dev129
3
+ Version: 0.1.dev132
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.dev129'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev129')
21
+ __version__ = version = '0.1.dev132'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev132')
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(
@@ -100,10 +100,12 @@ class Result:
100
100
  "Call .with_geometry() first to join the static geometry column before extracting GeoDataFrame."
101
101
  )
102
102
  import geopandas as gpd
103
- import shapely.wkb as wkb
103
+ import shapely
104
104
 
105
105
  geometry_col = self.entity.geometry_col
106
- geometries = [wkb.loads(b) for b in self.data[geometry_col].to_list()]
106
+ # Vectorised C-level decode — shapely.from_wkb operates on the whole
107
+ # numpy array at once, avoiding a Python-loop per row.
108
+ geometries = shapely.from_wkb(self.data[geometry_col].to_numpy())
107
109
  geo_series = gpd.GeoSeries(geometries, crs="EPSG:4326")
108
110
  return gpd.GeoDataFrame(
109
111
  self.data.drop(geometry_col).to_pandas(),
@@ -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:
@@ -162,12 +162,17 @@ def geoparquet(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> Non
162
162
  def geojson(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> None:
163
163
  """Export the result data to a GeoJSON file.
164
164
 
165
- This function uses DuckDB to write the data frame with spatial extensions.
165
+ This function uses GeoPandas to write the data frame. It prefers the
166
+ ``pyogrio`` engine (faster, C-backed) when available and falls back to
167
+ ``fiona`` automatically. The ``engine`` kwarg can override this:
168
+ ``engine="fiona"`` or ``engine="pyogrio"``.
166
169
 
167
170
  Args:
168
171
  result (Result): The Result object to export.
169
172
  path (str | pathlib.Path): Destination path for the GeoJSON file.
170
- **kwargs (Any): Additional options to pass to DuckDB's COPY statement.
173
+ **kwargs (Any): Additional options forwarded to
174
+ ``GeoDataFrame.to_file()`` (e.g. ``driver="GeoJSON"``,
175
+ ``engine="pyogrio"``).
171
176
 
172
177
  Example:
173
178
  >>> from core_lens.export import geojson
@@ -182,37 +187,20 @@ def geojson(result: "Result", path: str | pathlib.Path, **kwargs: Any) -> None:
182
187
  "Call .with_geometry() first to join the static geometry column before exporting to geojson."
183
188
  )
184
189
 
185
- df = result.df()
186
- geom_col = result.entity.geometry_col
187
-
188
- cols = []
189
- for c in df.columns:
190
- if c == geom_col:
191
- cols.append(f'ST_GeomFromWKB("{c}") AS "{c}"')
192
- else:
193
- cols.append(f'"{c}"')
194
-
195
- select_clause = ", ".join(cols)
196
-
197
- conn = _get_duckdb_conn()
190
+ gdf = result.gdf()
198
191
 
199
- path_str = str(path).replace("'", "''")
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
200
198
 
201
- options = ["FORMAT GDAL", "DRIVER 'GeoJSON'"]
202
- for k, v in kwargs.items():
203
- if isinstance(v, bool):
204
- options.append(f"{k.upper()} {'TRUE' if v else 'FALSE'}")
205
- elif isinstance(v, str):
206
- options.append(f"{k.upper()} '{v}'")
207
- else:
208
- options.append(f"{k.upper()} {v}")
199
+ engine = "pyogrio"
200
+ except ModuleNotFoundError:
201
+ engine = "fiona"
209
202
 
210
- options_str = ", ".join(options)
203
+ # Always write as GeoJSON unless the caller overrides driver.
204
+ kwargs.setdefault("driver", "GeoJSON")
211
205
 
212
- query = f"""
213
- COPY (
214
- SELECT {select_clause}
215
- FROM df
216
- ) TO '{path_str}' ({options_str});
217
- """
218
- conn.execute(query)
206
+ gdf.to_file(path, engine=engine, **kwargs)
@@ -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
 
@@ -152,8 +152,10 @@ def test_geoparquet_options_partition(
152
152
 
153
153
 
154
154
  def test_geojson_options(sample_result: Result, tmp_path: pathlib.Path) -> None:
155
+ """Unknown kwargs are forwarded to the write engine; unrecognised ones raise
156
+ ValueError (pyogrio) rather than silently being ignored. The test simply
157
+ verifies the call path is exercised without an unexpected exception type."""
155
158
  result_with_geom = sample_result.with_geometry()
156
- import duckdb
157
159
 
158
160
  out_path = tmp_path / "out_opts.json"
159
161
  try:
@@ -165,5 +167,6 @@ def test_geojson_options(sample_result: Result, tmp_path: pathlib.Path) -> None:
165
167
  flag=False,
166
168
  number=10,
167
169
  )
168
- except duckdb.BinderException:
170
+ except ValueError:
171
+ # pyogrio rejects unrecognised driver options — that's expected.
169
172
  pass
File without changes
File without changes
File without changes
File without changes