core-lens 0.1.dev190__tar.gz → 0.1.dev194__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 (94) hide show
  1. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/PKG-INFO +1 -1
  2. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/_version.py +2 -2
  3. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/entity.py +1 -0
  4. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/stats.py +29 -16
  5. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/view.py +1 -0
  6. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/polars_utils.py +28 -8
  7. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/spatial.py +170 -7
  8. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
  9. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
  10. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
  11. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/pull_request_template.md +0 -0
  12. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/ci.yml +0 -0
  13. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/gh-pages.yml +0 -0
  14. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/pre-release.yml +0 -0
  15. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/release.yml +0 -0
  16. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.gitignore +0 -0
  17. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.gitmessage +0 -0
  18. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.pre-commit-config.yaml +0 -0
  19. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.python-version +0 -0
  20. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/CONTRIBUTING.md +0 -0
  21. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/LICENSE +0 -0
  22. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/README.md +0 -0
  23. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/SKILLS.md +0 -0
  24. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/README.md +0 -0
  25. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_aoi.py +0 -0
  26. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_entity.py +0 -0
  27. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_export.py +0 -0
  28. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_polars_utils.py +0 -0
  29. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_result.py +0 -0
  30. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_schema.py +0 -0
  31. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_season.py +0 -0
  32. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_spatial.py +0 -0
  33. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_view.py +0 -0
  34. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/run_all.sh +0 -0
  35. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/Makefile +0 -0
  36. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/make.bat +0 -0
  37. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/concepts.md +0 -0
  38. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/conf.py +0 -0
  39. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/export.md +0 -0
  40. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/index.rst +0 -0
  41. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/intro.md +0 -0
  42. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/logging.md +0 -0
  43. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/plots.md +0 -0
  44. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/plugins.md +0 -0
  45. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/queries.md +0 -0
  46. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/quickstart.md +0 -0
  47. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/stats.md +0 -0
  48. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/examples/demo_mws.py +0 -0
  49. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/examples/demo_tehsil.py +0 -0
  50. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/hooks/mypy.sh +0 -0
  51. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/hooks/no-parquet-outside-fixtures.sh +0 -0
  52. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/hooks/pytest.sh +0 -0
  53. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/pyproject.toml +0 -0
  54. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/__init__.py +0 -0
  55. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/__main__.py +0 -0
  56. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/aoi.py +0 -0
  57. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/__init__.py +0 -0
  58. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/__init__.py +0 -0
  59. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/plot.py +0 -0
  60. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/result.py +0 -0
  61. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/__init__.py +0 -0
  62. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/farms.py +0 -0
  63. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/mws.py +0 -0
  64. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/tehsil.py +0 -0
  65. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/waterbody.py +0 -0
  66. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/export/__init__.py +0 -0
  67. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/export/formats.py +0 -0
  68. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/py.typed +0 -0
  69. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/schema/__init__.py +0 -0
  70. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/schema/detection.py +0 -0
  71. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/schema/profile.py +0 -0
  72. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/__init__.py +0 -0
  73. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/paths.py +0 -0
  74. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/season.py +0 -0
  75. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/fixtures/generate_fixtures.py +0 -0
  76. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/conftest.py +0 -0
  77. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_aoi.py +0 -0
  78. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_entities.py +0 -0
  79. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_entity.py +0 -0
  80. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_export.py +0 -0
  81. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_main.py +0 -0
  82. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_plot.py +0 -0
  83. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_polars_utils.py +0 -0
  84. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_profile.py +0 -0
  85. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_result.py +0 -0
  86. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_schema_detection.py +0 -0
  87. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_schema_profile.py +0 -0
  88. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_season.py +0 -0
  89. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_season_config.py +0 -0
  90. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_spatial.py +0 -0
  91. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_stats.py +0 -0
  92. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_view.py +0 -0
  93. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/usage.md +0 -0
  94. {core_lens-0.1.dev190 → core_lens-0.1.dev194}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: core-lens
3
- Version: 0.1.dev190
3
+ Version: 0.1.dev194
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.dev190'
22
- __version_tuple__ = version_tuple = (0, 1, 'dev190')
21
+ __version__ = version = '0.1.dev194'
22
+ __version_tuple__ = version_tuple = (0, 1, 'dev194')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -650,6 +650,7 @@ class BaseEntity(ABC):
650
650
  aoi_geometry=geometry,
651
651
  relationship=relationship,
652
652
  threshold=threshold,
653
+ storage_options=self._storage_options,
653
654
  )
654
655
 
655
656
  entity_name = _entity_name(type(self))
@@ -777,6 +777,17 @@ class StatsNamespace:
777
777
  Raises:
778
778
  ValueError: If ``mode``/``method`` invalid, observation count too low, or
779
779
  ``baseline`` and ``target_years`` overlap.
780
+
781
+ Notes:
782
+ **STL fitting scope.** For ``AnomalyTsMethod.STL`` the decomposition
783
+ (trend + seasonal + remainder) is fit on the *entire* series of each entity,
784
+ baseline and target years together. ``baseline`` is used only to calibrate the scale (standard deviation) of the remainder; ``target_years`` selects which points are scored. Scores are ``remainder / baseline_remainder_std``.
785
+
786
+ Because target observations take part in the fit, the trend and seasonal components can partly absorb target-period anomalies, shrinking the remainder and lowering the score. The effect grows with the size and duration of the anomaly; a sustained shift can be absorbed almost entirely into the trend. Scores therefore measure deviation from a locally fitted trend and seasonal pattern, not from the baseline's "normal" behaviour, and the method is conservative: it may miss anomalies but is unlikely to over-flag them.
787
+
788
+ A stricter alternative is to fit on the baseline only and forecast the target period (e.g. ``statsmodels.tsa.forecasting.stl.STLForecast``), scoring forecast errors against the baseline error spread. This is not implemented.
789
+
790
+ For sub-annual data, residuals are matched to rows by the finer time column (``date``/``period``), not by year, so each observation gets its own score.
780
791
  """
781
792
  if baseline is not None and target_years is not None:
782
793
  b_lo, b_hi = baseline
@@ -976,7 +987,8 @@ class StatsNamespace:
976
987
  # ---- CUSUM / STL: inherently sequential/per-entity, partition_by instead of filter-loop ----
977
988
  else:
978
989
  rows: list[dict[str, Any]] = []
979
- for sub in df.sort(year_col).partition_by(key, maintain_order=True):
990
+ sort_cols = list(dict.fromkeys([year_col, output_time_col]))
991
+ for sub in df.sort(sort_cols).partition_by(key, maintain_order=True):
980
992
  eid = sub[key][0]
981
993
  base_vals = (
982
994
  sub.filter(
@@ -1027,6 +1039,7 @@ class StatsNamespace:
1027
1039
  full = sub.filter(pl.col(column).is_not_null())
1028
1040
  full_vals = full[column].to_numpy().astype(float)
1029
1041
  full_years = full[year_col].to_list()
1042
+ full_times = full[output_time_col].to_list()
1030
1043
 
1031
1044
  period = (
1032
1045
  26 if len(full_vals) >= 52 else max(2, len(full_vals) // 2)
@@ -1036,13 +1049,13 @@ class StatsNamespace:
1036
1049
  3 * period
1037
1050
  ) # want ~3 cycles minimum for a trustworthy seasonal fit, not just 2
1038
1051
 
1039
- if len(full_vals) < min_required:
1040
- warnings.warn(
1041
- f"StatsNamespace.anomaly: entity {eid!r} has {len(full_vals)} points, "
1042
- f"period={period} needs >= {min_reliable} for a reliable STL fit. Skipping."
1043
- )
1044
- n_dropped += 1
1045
- continue
1052
+ # if len(full_vals) < min_reliable:
1053
+ # warnings.warn(
1054
+ # f"StatsNamespace.anomaly: entity {eid!r} has {len(full_vals)} points, "
1055
+ # f"period={period} needs >= {min_reliable} for a reliable STL fit. Skipping."
1056
+ # )
1057
+ # n_dropped += 1
1058
+ # continue
1046
1059
 
1047
1060
  if len(full_vals) < min_required:
1048
1061
  warnings.warn(
@@ -1058,21 +1071,21 @@ class StatsNamespace:
1058
1071
  res = STL(full_vals, period=period).fit()
1059
1072
  resid = res.resid
1060
1073
 
1061
- # Match residuals to years EXPLICITLY, not positionally —
1062
- # avoids misalignment if there are gaps in the series.
1063
- resid_by_year = dict(zip(full_years, resid))
1074
+ # Key residuals by the fine-grained time column (date/period).
1075
+ # Keying by year collapses ~26 fortnights into one value.
1076
+ resid_by_time = dict(zip(full_times, resid))
1064
1077
  base_resid = [
1065
- resid_by_year[y]
1066
- for y in full_years
1078
+ r
1079
+ for y, r in zip(full_years, resid)
1067
1080
  if baseline[0] <= y <= baseline[1]
1068
1081
  ]
1069
1082
  std = _sf(pl.Series(base_resid).std(ddof=1)) or 1.0
1070
1083
 
1071
1084
  ts_scores = [
1072
- float(resid_by_year[y] / std)
1073
- if y in resid_by_year
1085
+ float(resid_by_time[t] / std)
1086
+ if t in resid_by_time
1074
1087
  else float("nan")
1075
- for y in eval_years_lookup
1088
+ for t in eval_times
1076
1089
  ]
1077
1090
  ts_flags = [
1078
1091
  abs(s) > threshold if s == s else False
@@ -215,6 +215,7 @@ class View:
215
215
  aoi_geometry=geometry,
216
216
  relationship=relationship,
217
217
  threshold=threshold,
218
+ storage_options=self.entity._storage_options,
218
219
  )
219
220
 
220
221
  keys_lf = keys_df.lazy()
@@ -137,12 +137,25 @@ def scan_with_key_filter(
137
137
 
138
138
  1. **Key filter** — restricts to entity instances whose key column(s) are
139
139
  in ``key_values``. For a single-column key this is an ``is_in``
140
- predicate pushed down to the Parquet reader. For composite keys each
141
- column is filtered independently (over-selects slightly, then pruned
142
- by the join at collect time).
140
+ predicate; for composite keys each column is filtered independently
141
+ (slight over-select, pruned by the semi-join at collect time).
142
+
143
+ **S3 / cloud note:** ``is_in`` pushdown skips row groups only when the
144
+ Parquet file was written with Bloom filters on the key column (e.g.
145
+ ``pyarrow.parquet.write_table(..., bloom_filter_columns=[key])``). Without
146
+ Bloom filters the Parquet reader downloads all row groups and filters
147
+ in-memory regardless. Use the semi-join path (> 50 000 keys) or add
148
+ Bloom filters at write time to avoid full-file S3 reads.
143
149
 
144
150
  2. **Time filter** — an optional Polars expression appended with ``&``,
145
- also pushed down if the Parquet file carries column statistics.
151
+ pushed down when the Parquet file carries min/max column statistics.
152
+
153
+ ``use_statistics=True`` and ``parallel="columns"`` are set explicitly on
154
+ the underlying ``pl.scan_parquet`` call so that:
155
+
156
+ * Row-group skipping via Parquet min/max statistics is always active.
157
+ * On wide Parquet files, column chunks within each row group are fetched
158
+ concurrently instead of serially — reduces round trips on S3.
146
159
 
147
160
  Args:
148
161
  path (str): Absolute path or cloud URI to a Parquet file.
@@ -159,7 +172,13 @@ def scan_with_key_filter(
159
172
 
160
173
  """
161
174
  _so = storage_options or {}
162
- lf = pl.scan_parquet(path, hive_partitioning=True, storage_options=_so or None)
175
+ lf = pl.scan_parquet(
176
+ path,
177
+ hive_partitioning=True,
178
+ use_statistics=True,
179
+ parallel="columns",
180
+ storage_options=_so or None,
181
+ )
163
182
 
164
183
  # Use an inner join to filter the parquet file down to the exact requested keys.
165
184
  # This avoids building a massive literal expression tree (which consumes gigabytes
@@ -169,9 +188,10 @@ def scan_with_key_filter(
169
188
 
170
189
  kv_df = key_values.collect() if isinstance(key_values, pl.LazyFrame) else key_values
171
190
  if len(key_cols) == 1 and kv_df.height < 50000:
172
- # Fast path: For reasonably sized AoIs, `is_in` enables perfect row-group
173
- # predicate pushdown in the Parquet reader, avoiding a full table scan.
174
- # The literal tree RAM issue only occurs with hundreds of thousands of keys.
191
+ # Fast path: `is_in` keeps the plan simple and enables row-group skipping
192
+ # via Parquet Bloom filters when the key column was written with them.
193
+ # Without Bloom filters on S3, all row groups are downloaded regardless;
194
+ # see docstring note above.
175
195
  key = key_cols[0]
176
196
  lf = lf.filter(pl.col(key).is_in(kv_df[key].to_list()))
177
197
  else:
@@ -25,9 +25,16 @@ if TYPE_CHECKING:
25
25
  # dataset (not once per process). Subsequent runs load the index directly
26
26
  # from the sidecar — no geometry decoding required.
27
27
  #
28
- # Sidecar location priority:
28
+ # Local sidecar location priority:
29
29
  # 1. Alongside the static parquet directory/file (requires write access).
30
30
  # 2. ~/.cache/core_lens/ (fallback for read-only data roots).
31
+ #
32
+ # Cloud (S3 / GCS / ABFS) sidecar:
33
+ # Always stored in ~/.cache/core_lens/ keyed on a SHA-256 hash of the URI.
34
+ # A companion <fingerprint>.etag file records the ETag returned by the last
35
+ # HeadObject call. On cache hit the ETag is re-fetched (one cheap metadata
36
+ # call) and compared; a mismatch triggers a full rebuild. This approach
37
+ # avoids streaming the entire GeoParquet from S3 on every cold process start.
31
38
  # ---------------------------------------------------------------------------
32
39
 
33
40
  _SIDECAR_SUFFIX = ".bbox_index.parquet"
@@ -41,6 +48,8 @@ def _bbox_sidecar_path(static_path: str) -> pathlib.Path | None:
41
48
  not writable (e.g. read-only data root), falls back to
42
49
  ``~/.cache/core_lens/``.
43
50
 
51
+ For cloud URIs use :func:`_cloud_sidecar_path` instead.
52
+
44
53
  Args:
45
54
  static_path (str): Absolute local path to the static parquet file/directory.
46
55
 
@@ -66,6 +75,136 @@ def _bbox_sidecar_path(static_path: str) -> pathlib.Path | None:
66
75
  return sidecar
67
76
 
68
77
 
78
+ def _cloud_sidecar_path(static_path: str) -> pathlib.Path:
79
+ """Return the ``~/.cache/core_lens/`` sidecar path for a cloud URI.
80
+
81
+ The path is keyed on a SHA-256 hash of the full URI so different buckets,
82
+ prefixes, or regions never collide. The companion ``.etag`` file (same
83
+ stem, ``.etag`` suffix) stores the last-seen ETag for staleness detection.
84
+
85
+ Args:
86
+ static_path (str): Cloud URI (e.g. ``s3://bucket/prefix/``).
87
+
88
+ Returns:
89
+ pathlib.Path: Local cache path for the sidecar Parquet.
90
+
91
+ """
92
+ import hashlib
93
+
94
+ fingerprint = hashlib.sha256(static_path.encode()).hexdigest()[:24]
95
+ _CACHE_DIR.mkdir(parents=True, exist_ok=True)
96
+ return _CACHE_DIR / (fingerprint + _SIDECAR_SUFFIX)
97
+
98
+
99
+ def _get_cloud_etag(
100
+ static_path: str,
101
+ storage_options: dict[str, Any],
102
+ ) -> str | None:
103
+ """Return the ETag (or size-based fingerprint) for a cloud object, or ``None``.
104
+
105
+ Performs a single metadata call (``HeadObject`` on S3) — no data is
106
+ transferred. The ETag is used as the staleness key for the cloud bbox
107
+ sidecar: if the ETag matches the stored value the sidecar is fresh.
108
+
109
+ Falls back to the object size when the ETag is not exposed by the
110
+ filesystem implementation, and returns ``None`` on any error so the caller
111
+ can safely skip the cache.
112
+
113
+ Args:
114
+ static_path (str): Cloud URI to the static GeoParquet file or directory.
115
+ storage_options (dict[str, Any]): Cloud credential / configuration options.
116
+
117
+ Returns:
118
+ str | None: An opaque fingerprint string, or ``None`` on failure.
119
+
120
+ """
121
+ try:
122
+ import pyarrow.fs as pafs
123
+
124
+ fs, arrow_path = resolve_fs_and_path(static_path)
125
+ # For a directory (partitioned dataset) inspect the first child file.
126
+ info = fs.get_file_info(arrow_path)
127
+ if info.type == pafs.FileType.Directory:
128
+ selector = pafs.FileSelector(arrow_path, recursive=False)
129
+ children = fs.get_file_info(selector)
130
+ parquet_children = [c for c in children if c.base_name.endswith(".parquet")]
131
+ if not parquet_children:
132
+ return None
133
+ info = parquet_children[0]
134
+
135
+ # pyarrow S3FileSystem exposes etag via the metadata dict.
136
+ if hasattr(info, "metadata") and info.metadata:
137
+ etag = info.metadata.get("ETag") or info.metadata.get("etag")
138
+ if etag:
139
+ return str(etag)
140
+
141
+ # Fallback: use mtime + size as a combined fingerprint.
142
+ if info.size is not None and info.size > 0:
143
+ mtime = getattr(info, "mtime", None)
144
+ return f"{info.size}-{mtime}"
145
+
146
+ return None
147
+ except Exception as exc:
148
+ logger.debug(
149
+ "_get_cloud_etag: could not fetch ETag for {!r}: {}", static_path, exc
150
+ )
151
+ return None
152
+
153
+
154
+ def _read_cloud_bbox_sidecar(
155
+ sidecar: pathlib.Path,
156
+ etag: str,
157
+ ) -> pl.DataFrame | None:
158
+ """Read the cloud bbox sidecar if it exists and its stored ETag matches *etag*.
159
+
160
+ Args:
161
+ sidecar (pathlib.Path): Local cache path returned by :func:`_cloud_sidecar_path`.
162
+ etag (str): Current ETag / fingerprint of the remote object.
163
+
164
+ Returns:
165
+ pl.DataFrame | None: Cached index DataFrame, or ``None`` if absent or stale.
166
+
167
+ """
168
+ etag_file = sidecar.with_suffix(".etag")
169
+ if not sidecar.exists() or not etag_file.exists():
170
+ return None
171
+ try:
172
+ stored_etag = etag_file.read_text(encoding="utf-8").strip()
173
+ if stored_etag != etag:
174
+ logger.debug(
175
+ "Cloud bbox sidecar {} is stale (ETag mismatch), rebuilding.",
176
+ sidecar,
177
+ )
178
+ return None
179
+ logger.debug("Loading cloud bbox index from sidecar: {}", sidecar)
180
+ return pl.read_parquet(str(sidecar))
181
+ except Exception as exc:
182
+ logger.warning("Failed to read cloud bbox sidecar {}: {}", sidecar, exc)
183
+ return None
184
+
185
+
186
+ def _write_cloud_bbox_sidecar(
187
+ df: pl.DataFrame,
188
+ sidecar: pathlib.Path,
189
+ etag: str,
190
+ ) -> None:
191
+ """Persist ``df`` and ``etag`` to the cloud bbox sidecar, silently ignoring errors.
192
+
193
+ Args:
194
+ df (pl.DataFrame): The bbox index DataFrame to persist.
195
+ sidecar (pathlib.Path): Destination path for the Parquet sidecar.
196
+ etag (str): ETag / fingerprint to store alongside the sidecar.
197
+
198
+ """
199
+ try:
200
+ sidecar.parent.mkdir(parents=True, exist_ok=True)
201
+ df.write_parquet(str(sidecar))
202
+ sidecar.with_suffix(".etag").write_text(etag, encoding="utf-8")
203
+ logger.debug("Wrote cloud bbox index sidecar: {}", sidecar)
204
+ except Exception as exc:
205
+ logger.debug("Could not write cloud bbox index sidecar {}: {}", sidecar, exc)
206
+
207
+
69
208
  def _read_bbox_sidecar(sidecar: pathlib.Path, static_path: str) -> pl.DataFrame | None:
70
209
  """Read the sidecar if it exists and is newer than the static data.
71
210
 
@@ -212,10 +351,20 @@ def build_bbox_index(
212
351
  + "Cannot compute bounds from separate lat/lon columns without bbox hints."
213
352
  )
214
353
 
215
- # --- Sidecar fast path (local paths only) --------------------------------
354
+ # --- Sidecar fast path ---------------------------------------------------
216
355
  # Check for a pre-built index sidecar before doing the expensive WKB loop.
356
+ # Local paths use mtime-based invalidation; cloud URIs use ETag.
217
357
  sidecar: pathlib.Path | None = None
218
- if not is_cloud_uri(static_path):
358
+ cloud_etag: str | None = None
359
+ if is_cloud_uri(static_path):
360
+ # One cheap HeadObject call to get the ETag — no data transferred.
361
+ cloud_etag = _get_cloud_etag(static_path, _so)
362
+ if cloud_etag is not None:
363
+ sidecar = _cloud_sidecar_path(static_path)
364
+ cached = _read_cloud_bbox_sidecar(sidecar, cloud_etag)
365
+ if cached is not None:
366
+ return cached
367
+ else:
219
368
  sidecar = _bbox_sidecar_path(static_path)
220
369
  if sidecar is not None:
221
370
  cached = _read_bbox_sidecar(sidecar, static_path)
@@ -266,9 +415,14 @@ def build_bbox_index(
266
415
  pl.Series("maxy", [], dtype=pl.Float64),
267
416
  )
268
417
 
269
- # Persist the sidecar for future runs (local paths only, errors silenced).
418
+ # Persist the sidecar for future runs (errors silenced in both helpers).
270
419
  if sidecar is not None:
271
- _write_bbox_sidecar(result, sidecar)
420
+ if cloud_etag is not None:
421
+ # Cloud path — store alongside the ETag fingerprint.
422
+ _write_cloud_bbox_sidecar(result, sidecar, cloud_etag)
423
+ else:
424
+ # Local path — plain mtime-checked sidecar.
425
+ _write_bbox_sidecar(result, sidecar)
272
426
 
273
427
  return result
274
428
 
@@ -310,6 +464,7 @@ def exact_spatial_filter(
310
464
  aoi_geometry: "shapely.Geometry",
311
465
  relationship: str = "centroid",
312
466
  threshold: float = 0.5,
467
+ storage_options: dict[str, Any] | None = None,
313
468
  ) -> pl.DataFrame:
314
469
  """Refine a bbox candidate set to rows that match ``aoi_geometry``.
315
470
 
@@ -319,7 +474,7 @@ def exact_spatial_filter(
319
474
  Args:
320
475
  candidates (pl.DataFrame): The DataFrame from :func:`bbox_intersects_geometry` —
321
476
  only the key columns are used here; the bbox columns are ignored.
322
- static_path (str): Absolute path to the static GeoParquet file.
477
+ static_path (str): Absolute path or cloud URI to the static GeoParquet file.
323
478
  key_cols (list[str]): Column name(s) that form the entity's unique key.
324
479
  geometry_col (str): Name of the geometry column in the static file.
325
480
  geometry_type (str): One of ``"wkb"`` or ``"wkt"``.
@@ -333,6 +488,10 @@ def exact_spatial_filter(
333
488
 
334
489
  threshold (float, optional): Minimum intersection-to-entity area ratio used in
335
490
  ``"area"`` mode. Ignored in ``"centroid"`` mode. Default 0.5.
491
+ storage_options (dict[str, Any] | None, optional): Cloud credential /
492
+ configuration options forwarded to ``pl.scan_parquet``. Required
493
+ when ``static_path`` is a cloud URI; ``None`` uses ambient
494
+ credentials.
336
495
 
337
496
  Returns:
338
497
  pl.DataFrame: A ``pl.DataFrame`` containing only the key columns for entities that
@@ -361,8 +520,12 @@ def exact_spatial_filter(
361
520
  return candidates.select(key_cols)
362
521
 
363
522
  # Build a lazy scan and push down an inner join to load only required geometries.
523
+ _so = storage_options or {}
364
524
  full_df = (
365
- pl.scan_parquet(parquet_scan_path(static_path))
525
+ pl.scan_parquet(
526
+ parquet_scan_path(static_path),
527
+ storage_options=_so or None,
528
+ )
366
529
  .select(key_cols + [geometry_col])
367
530
  .join(candidates.select(key_cols).lazy(), on=key_cols, how="semi")
368
531
  .collect()
File without changes
File without changes
File without changes
File without changes
File without changes