core-lens 0.1.dev191__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.dev191 → core_lens-0.1.dev194}/PKG-INFO +1 -1
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/_version.py +2 -2
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/entity.py +1 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/view.py +1 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/polars_utils.py +28 -8
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/spatial.py +170 -7
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/blank-proposal.yaml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/bug-report.yaml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/ISSUE_TEMPLATE/feature-request.yaml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/pull_request_template.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/ci.yml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/gh-pages.yml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/pre-release.yml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.github/workflows/release.yml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.gitignore +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.gitmessage +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.pre-commit-config.yaml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/.python-version +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/CONTRIBUTING.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/LICENSE +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/README.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/SKILLS.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/README.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_aoi.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_entity.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_export.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_polars_utils.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_result.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_schema.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_season.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_spatial.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/bench_view.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/benchmarks/run_all.sh +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/Makefile +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/make.bat +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/concepts.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/conf.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/export.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/index.rst +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/intro.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/logging.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/plots.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/plugins.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/queries.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/quickstart.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/docs/source/stats.md +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/examples/demo_mws.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/examples/demo_tehsil.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/hooks/mypy.sh +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/hooks/no-parquet-outside-fixtures.sh +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/hooks/pytest.sh +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/pyproject.toml +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/__main__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/aoi.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/plot.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/namespaces/stats.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/base/result.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/farms.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/mws.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/tehsil.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/entities/waterbody.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/export/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/export/formats.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/py.typed +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/schema/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/schema/detection.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/schema/profile.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/__init__.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/paths.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/src/core_lens/utils/season.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/fixtures/generate_fixtures.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/conftest.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_aoi.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_entities.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_entity.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_export.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_main.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_plot.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_polars_utils.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_profile.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_result.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_schema_detection.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_schema_profile.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_season.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_season_config.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_spatial.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_stats.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/tests/unit/test_view.py +0 -0
- {core_lens-0.1.dev191 → core_lens-0.1.dev194}/usage.md +0 -0
- {core_lens-0.1.dev191 → 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
|
|
@@ -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
|
|
File without changes
|