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.
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/PKG-INFO +1 -1
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/entity.py +1 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/stats.py +29 -16
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/view.py +1 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/polars_utils.py +28 -8
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/spatial.py +170 -7
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.gitignore +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.gitmessage +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/.python-version +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/LICENSE +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/README.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/SKILLS.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/Makefile +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/make.bat +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/export.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/pyproject.toml +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/aoi.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/farms.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/tests/unit/test_view.py +0 -0
- {core_lens-0.1.dev190 → core_lens-0.1.dev194}/usage.md +0 -0
- {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.
|
|
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.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 1, '
|
|
21
|
+
__version__ = version = '0.1.dev194'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 'dev194')
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -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
|
-
|
|
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) <
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
#
|
|
1062
|
-
#
|
|
1063
|
-
|
|
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
|
-
|
|
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(
|
|
1073
|
-
if
|
|
1085
|
+
float(resid_by_time[t] / std)
|
|
1086
|
+
if t in resid_by_time
|
|
1074
1087
|
else float("nan")
|
|
1075
|
-
for
|
|
1088
|
+
for t in eval_times
|
|
1076
1089
|
]
|
|
1077
1090
|
ts_flags = [
|
|
1078
1091
|
abs(s) > threshold if s == s else False
|
|
@@ -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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
173
|
-
#
|
|
174
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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 (
|
|
418
|
+
# Persist the sidecar for future runs (errors silenced in both helpers).
|
|
270
419
|
if sidecar is not None:
|
|
271
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|