agrigee-lite-client 3.4.1__py3-none-any.whl
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.
- agrigee_lite_client/__init__.py +25 -0
- agrigee_lite_client/_async_client.py +205 -0
- agrigee_lite_client/_client.py +213 -0
- agrigee_lite_client/_exceptions.py +57 -0
- agrigee_lite_client/_geo_compat.py +47 -0
- agrigee_lite_client/_geoparquet.py +66 -0
- agrigee_lite_client/_get.py +111 -0
- agrigee_lite_client/_satellite_dates.py +95 -0
- agrigee_lite_client/_validation.py +91 -0
- agrigee_lite_client/_version.py +10 -0
- agrigee_lite_client-3.4.1.dist-info/METADATA +144 -0
- agrigee_lite_client-3.4.1.dist-info/RECORD +13 -0
- agrigee_lite_client-3.4.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Thin HTTP client for the AgriGEE.lite REST API. See SPECS.md."""
|
|
2
|
+
|
|
3
|
+
from agrigee_lite_client._async_client import AsyncAgriGEEClient
|
|
4
|
+
from agrigee_lite_client._client import AgriGEEClient
|
|
5
|
+
from agrigee_lite_client._exceptions import (
|
|
6
|
+
AgriGEEClientError,
|
|
7
|
+
AgriGEEHTTPError,
|
|
8
|
+
AgriGEEJobError,
|
|
9
|
+
AgriGEEJobTimeoutError,
|
|
10
|
+
AgriGEEUnknownSatelliteError,
|
|
11
|
+
AgriGEEVersionMismatchError,
|
|
12
|
+
)
|
|
13
|
+
from agrigee_lite_client._version import __version__
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AgriGEEClient",
|
|
17
|
+
"AgriGEEClientError",
|
|
18
|
+
"AgriGEEHTTPError",
|
|
19
|
+
"AgriGEEJobError",
|
|
20
|
+
"AgriGEEJobTimeoutError",
|
|
21
|
+
"AgriGEEUnknownSatelliteError",
|
|
22
|
+
"AgriGEEVersionMismatchError",
|
|
23
|
+
"AsyncAgriGEEClient",
|
|
24
|
+
"__version__",
|
|
25
|
+
]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Asynchronous AsyncAgriGEEClient — mirrors _client.py. See SPECS.md §4.
|
|
2
|
+
|
|
3
|
+
Deliberately duplicated rather than unified with the sync client (see
|
|
4
|
+
SPECS.md §4): only the I/O (httpx calls, sleep) differs, and ``_get.py``
|
|
5
|
+
already carries every bit of logic that's actually shared.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import zipfile
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
import polars as pl
|
|
16
|
+
from shapely.geometry.base import BaseGeometry
|
|
17
|
+
|
|
18
|
+
from agrigee_lite_client import _geo_compat, _get, _satellite_dates, _validation
|
|
19
|
+
from agrigee_lite_client._client import _raise_for_status
|
|
20
|
+
from agrigee_lite_client._exceptions import AgriGEEJobError, AgriGEEJobTimeoutError, AgriGEEVersionMismatchError
|
|
21
|
+
from agrigee_lite_client._version import __version__
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _AsyncGet:
|
|
25
|
+
"""Bound to a single ``AsyncAgriGEEClient`` — mirrors ``agl.get.*``."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, client: AsyncAgriGEEClient) -> None:
|
|
28
|
+
self._client = client
|
|
29
|
+
|
|
30
|
+
async def sits(
|
|
31
|
+
self,
|
|
32
|
+
geometry: BaseGeometry,
|
|
33
|
+
start_date: str,
|
|
34
|
+
end_date: str,
|
|
35
|
+
satellite: str,
|
|
36
|
+
satellite_params: dict[str, Any] | None = None,
|
|
37
|
+
reducers: list[str] | None = None,
|
|
38
|
+
subsampling_max_pixels: float = 1_000,
|
|
39
|
+
) -> pl.DataFrame:
|
|
40
|
+
"""Naive: wraps a 1-row frame and calls ``multiple_sits`` (SPECS.md §6)."""
|
|
41
|
+
frame = _get.build_single_sits_frame(geometry, start_date, end_date)
|
|
42
|
+
return await self.multiple_sits(
|
|
43
|
+
frame,
|
|
44
|
+
satellite=satellite,
|
|
45
|
+
satellite_params=satellite_params,
|
|
46
|
+
reducers=reducers,
|
|
47
|
+
subsampling_max_pixels=subsampling_max_pixels,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def multiple_sits(
|
|
51
|
+
self,
|
|
52
|
+
gdf,
|
|
53
|
+
satellite: str,
|
|
54
|
+
satellite_params: dict[str, Any] | None = None,
|
|
55
|
+
reducers: list[str] | None = None,
|
|
56
|
+
start_date_column: str = "start_date",
|
|
57
|
+
end_date_column: str = "end_date",
|
|
58
|
+
original_index_column: str = "original_index",
|
|
59
|
+
subsampling_max_pixels: float = 1_000,
|
|
60
|
+
chunksize: int = 10,
|
|
61
|
+
max_parallel_downloads: int = 40,
|
|
62
|
+
max_retries_per_chunk: int = 8,
|
|
63
|
+
force_redownload: bool = False,
|
|
64
|
+
) -> pl.DataFrame:
|
|
65
|
+
await self._client._ensure_server_version_matches()
|
|
66
|
+
|
|
67
|
+
frame = _geo_compat.to_wkb_frame(gdf)
|
|
68
|
+
satellite_start, satellite_end = _satellite_dates.get_satellite_date_range(satellite, satellite_params)
|
|
69
|
+
validated = _validation.drop_rows_outside_satellite_range(
|
|
70
|
+
frame,
|
|
71
|
+
satellite_start=satellite_start,
|
|
72
|
+
satellite_end=satellite_end,
|
|
73
|
+
start_date_column=start_date_column,
|
|
74
|
+
end_date_column=end_date_column,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
parquet_bytes, form = _get.build_multiple_sits_upload(
|
|
78
|
+
validated,
|
|
79
|
+
satellite=satellite,
|
|
80
|
+
satellite_params=satellite_params,
|
|
81
|
+
reducers=reducers,
|
|
82
|
+
start_date_column=start_date_column,
|
|
83
|
+
end_date_column=end_date_column,
|
|
84
|
+
original_index_column=original_index_column,
|
|
85
|
+
subsampling_max_pixels=subsampling_max_pixels,
|
|
86
|
+
chunksize=chunksize,
|
|
87
|
+
max_parallel_downloads=max_parallel_downloads,
|
|
88
|
+
max_retries_per_chunk=max_retries_per_chunk,
|
|
89
|
+
force_redownload=force_redownload,
|
|
90
|
+
)
|
|
91
|
+
response = await self._client._http.post(
|
|
92
|
+
"/sits/multiple/file",
|
|
93
|
+
files={"file": ("geometries.parquet", parquet_bytes, "application/octet-stream")},
|
|
94
|
+
data=form,
|
|
95
|
+
)
|
|
96
|
+
_raise_for_status(response)
|
|
97
|
+
job_id = response.json()["id"]
|
|
98
|
+
await self._client._wait_for_job(job_id)
|
|
99
|
+
content = await self._client._download_job(job_id)
|
|
100
|
+
return _get.parse_sits_download(content)
|
|
101
|
+
|
|
102
|
+
async def image(
|
|
103
|
+
self,
|
|
104
|
+
geometry: BaseGeometry,
|
|
105
|
+
start_date: str,
|
|
106
|
+
end_date: str,
|
|
107
|
+
satellite: str,
|
|
108
|
+
satellite_params: dict[str, Any] | None = None,
|
|
109
|
+
invalid_images_threshold: float = 0.5,
|
|
110
|
+
max_parallel_downloads: int = 40,
|
|
111
|
+
force_redownload: bool = False,
|
|
112
|
+
image_indices: list[int] | None = None,
|
|
113
|
+
scale: float | None = None,
|
|
114
|
+
dimensions: int | str | None = None,
|
|
115
|
+
) -> zipfile.ZipFile:
|
|
116
|
+
body = _get.build_images_request(
|
|
117
|
+
geometry,
|
|
118
|
+
start_date=start_date,
|
|
119
|
+
end_date=end_date,
|
|
120
|
+
satellite=satellite,
|
|
121
|
+
satellite_params=satellite_params,
|
|
122
|
+
invalid_images_threshold=invalid_images_threshold,
|
|
123
|
+
max_parallel_downloads=max_parallel_downloads,
|
|
124
|
+
force_redownload=force_redownload,
|
|
125
|
+
image_indices=image_indices,
|
|
126
|
+
scale=scale,
|
|
127
|
+
dimensions=dimensions,
|
|
128
|
+
)
|
|
129
|
+
response = await self._client._http.post("/images", json=body)
|
|
130
|
+
_raise_for_status(response)
|
|
131
|
+
job_id = response.json()["id"]
|
|
132
|
+
await self._client._wait_for_job(job_id)
|
|
133
|
+
content = await self._client._download_job(job_id)
|
|
134
|
+
return _get.parse_image_download(content)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AsyncAgriGEEClient:
|
|
138
|
+
"""Asynchronous client for the AgriGEE.lite REST API. See SPECS.md."""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
base_url: str,
|
|
143
|
+
*,
|
|
144
|
+
timeout: float = 60.0,
|
|
145
|
+
poll_interval: float = 1.0,
|
|
146
|
+
poll_timeout: float | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
self._http = httpx.AsyncClient(base_url=base_url, timeout=timeout)
|
|
149
|
+
self._poll_interval = poll_interval
|
|
150
|
+
self._poll_timeout = poll_timeout
|
|
151
|
+
self._version_checked = False
|
|
152
|
+
self.get = _AsyncGet(self)
|
|
153
|
+
|
|
154
|
+
async def __aenter__(self) -> AsyncAgriGEEClient:
|
|
155
|
+
return self
|
|
156
|
+
|
|
157
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
158
|
+
await self.aclose()
|
|
159
|
+
|
|
160
|
+
async def aclose(self) -> None:
|
|
161
|
+
await self._http.aclose()
|
|
162
|
+
|
|
163
|
+
async def health(self) -> bool:
|
|
164
|
+
response = await self._http.get("/health")
|
|
165
|
+
_raise_for_status(response)
|
|
166
|
+
return response.json().get("status") == "ok"
|
|
167
|
+
|
|
168
|
+
async def list_satellites(self) -> list[str]:
|
|
169
|
+
response = await self._http.get("/satellites")
|
|
170
|
+
_raise_for_status(response)
|
|
171
|
+
return response.json()
|
|
172
|
+
|
|
173
|
+
async def _ensure_server_version_matches(self) -> None:
|
|
174
|
+
"""Checked once per instance, before trusting the embedded satellite
|
|
175
|
+
date table (_satellite_dates.py) — see AgriGEEVersionMismatchError.
|
|
176
|
+
"""
|
|
177
|
+
if self._version_checked:
|
|
178
|
+
return
|
|
179
|
+
response = await self._http.get("/version")
|
|
180
|
+
_raise_for_status(response)
|
|
181
|
+
server_version = response.json().get("version")
|
|
182
|
+
if server_version != __version__:
|
|
183
|
+
raise AgriGEEVersionMismatchError(__version__, server_version)
|
|
184
|
+
self._version_checked = True
|
|
185
|
+
|
|
186
|
+
async def _wait_for_job(self, job_id: str) -> None:
|
|
187
|
+
elapsed = 0.0
|
|
188
|
+
while True:
|
|
189
|
+
response = await self._http.get(f"/jobs/{job_id}")
|
|
190
|
+
_raise_for_status(response)
|
|
191
|
+
job = response.json()
|
|
192
|
+
status = job["status"]
|
|
193
|
+
if status == "completed":
|
|
194
|
+
return
|
|
195
|
+
if status == "failed":
|
|
196
|
+
raise AgriGEEJobError(job_id, job.get("error"))
|
|
197
|
+
if self._poll_timeout is not None and elapsed >= self._poll_timeout:
|
|
198
|
+
raise AgriGEEJobTimeoutError(job_id, self._poll_timeout)
|
|
199
|
+
await asyncio.sleep(self._poll_interval)
|
|
200
|
+
elapsed += self._poll_interval
|
|
201
|
+
|
|
202
|
+
async def _download_job(self, job_id: str) -> bytes:
|
|
203
|
+
response = await self._http.get(f"/jobs/{job_id}/download")
|
|
204
|
+
_raise_for_status(response)
|
|
205
|
+
return response.content
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Synchronous AgriGEEClient. See SPECS.md §4 and §6."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import time
|
|
7
|
+
import zipfile
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import polars as pl
|
|
12
|
+
from shapely.geometry.base import BaseGeometry
|
|
13
|
+
|
|
14
|
+
from agrigee_lite_client import _geo_compat, _get, _satellite_dates, _validation
|
|
15
|
+
from agrigee_lite_client._exceptions import (
|
|
16
|
+
AgriGEEHTTPError,
|
|
17
|
+
AgriGEEJobError,
|
|
18
|
+
AgriGEEJobTimeoutError,
|
|
19
|
+
AgriGEEVersionMismatchError,
|
|
20
|
+
)
|
|
21
|
+
from agrigee_lite_client._version import __version__
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _raise_for_status(response: httpx.Response) -> None:
|
|
25
|
+
if response.is_error:
|
|
26
|
+
detail = response.text
|
|
27
|
+
with contextlib.suppress(Exception):
|
|
28
|
+
detail = response.json().get("detail", detail)
|
|
29
|
+
raise AgriGEEHTTPError(response.status_code, detail)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _SyncGet:
|
|
33
|
+
"""Bound to a single ``AgriGEEClient`` — mirrors ``agl.get.*``."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, client: AgriGEEClient) -> None:
|
|
36
|
+
self._client = client
|
|
37
|
+
|
|
38
|
+
def sits(
|
|
39
|
+
self,
|
|
40
|
+
geometry: BaseGeometry,
|
|
41
|
+
start_date: str,
|
|
42
|
+
end_date: str,
|
|
43
|
+
satellite: str,
|
|
44
|
+
satellite_params: dict[str, Any] | None = None,
|
|
45
|
+
reducers: list[str] | None = None,
|
|
46
|
+
subsampling_max_pixels: float = 1_000,
|
|
47
|
+
) -> pl.DataFrame:
|
|
48
|
+
"""Naive: wraps a 1-row frame and calls ``multiple_sits`` (SPECS.md §6)."""
|
|
49
|
+
frame = _get.build_single_sits_frame(geometry, start_date, end_date)
|
|
50
|
+
return self.multiple_sits(
|
|
51
|
+
frame,
|
|
52
|
+
satellite=satellite,
|
|
53
|
+
satellite_params=satellite_params,
|
|
54
|
+
reducers=reducers,
|
|
55
|
+
subsampling_max_pixels=subsampling_max_pixels,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def multiple_sits(
|
|
59
|
+
self,
|
|
60
|
+
gdf,
|
|
61
|
+
satellite: str,
|
|
62
|
+
satellite_params: dict[str, Any] | None = None,
|
|
63
|
+
reducers: list[str] | None = None,
|
|
64
|
+
start_date_column: str = "start_date",
|
|
65
|
+
end_date_column: str = "end_date",
|
|
66
|
+
original_index_column: str = "original_index",
|
|
67
|
+
subsampling_max_pixels: float = 1_000,
|
|
68
|
+
chunksize: int = 10,
|
|
69
|
+
max_parallel_downloads: int = 40,
|
|
70
|
+
max_retries_per_chunk: int = 8,
|
|
71
|
+
force_redownload: bool = False,
|
|
72
|
+
) -> pl.DataFrame:
|
|
73
|
+
self._client._ensure_server_version_matches()
|
|
74
|
+
|
|
75
|
+
frame = _geo_compat.to_wkb_frame(gdf)
|
|
76
|
+
satellite_start, satellite_end = _satellite_dates.get_satellite_date_range(satellite, satellite_params)
|
|
77
|
+
validated = _validation.drop_rows_outside_satellite_range(
|
|
78
|
+
frame,
|
|
79
|
+
satellite_start=satellite_start,
|
|
80
|
+
satellite_end=satellite_end,
|
|
81
|
+
start_date_column=start_date_column,
|
|
82
|
+
end_date_column=end_date_column,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
parquet_bytes, form = _get.build_multiple_sits_upload(
|
|
86
|
+
validated,
|
|
87
|
+
satellite=satellite,
|
|
88
|
+
satellite_params=satellite_params,
|
|
89
|
+
reducers=reducers,
|
|
90
|
+
start_date_column=start_date_column,
|
|
91
|
+
end_date_column=end_date_column,
|
|
92
|
+
original_index_column=original_index_column,
|
|
93
|
+
subsampling_max_pixels=subsampling_max_pixels,
|
|
94
|
+
chunksize=chunksize,
|
|
95
|
+
max_parallel_downloads=max_parallel_downloads,
|
|
96
|
+
max_retries_per_chunk=max_retries_per_chunk,
|
|
97
|
+
force_redownload=force_redownload,
|
|
98
|
+
)
|
|
99
|
+
response = self._client._http.post(
|
|
100
|
+
"/sits/multiple/file",
|
|
101
|
+
files={"file": ("geometries.parquet", parquet_bytes, "application/octet-stream")},
|
|
102
|
+
data=form,
|
|
103
|
+
)
|
|
104
|
+
_raise_for_status(response)
|
|
105
|
+
job_id = response.json()["id"]
|
|
106
|
+
self._client._wait_for_job(job_id)
|
|
107
|
+
content = self._client._download_job(job_id)
|
|
108
|
+
return _get.parse_sits_download(content)
|
|
109
|
+
|
|
110
|
+
def image(
|
|
111
|
+
self,
|
|
112
|
+
geometry: BaseGeometry,
|
|
113
|
+
start_date: str,
|
|
114
|
+
end_date: str,
|
|
115
|
+
satellite: str,
|
|
116
|
+
satellite_params: dict[str, Any] | None = None,
|
|
117
|
+
invalid_images_threshold: float = 0.5,
|
|
118
|
+
max_parallel_downloads: int = 40,
|
|
119
|
+
force_redownload: bool = False,
|
|
120
|
+
image_indices: list[int] | None = None,
|
|
121
|
+
scale: float | None = None,
|
|
122
|
+
dimensions: int | str | None = None,
|
|
123
|
+
) -> zipfile.ZipFile:
|
|
124
|
+
body = _get.build_images_request(
|
|
125
|
+
geometry,
|
|
126
|
+
start_date=start_date,
|
|
127
|
+
end_date=end_date,
|
|
128
|
+
satellite=satellite,
|
|
129
|
+
satellite_params=satellite_params,
|
|
130
|
+
invalid_images_threshold=invalid_images_threshold,
|
|
131
|
+
max_parallel_downloads=max_parallel_downloads,
|
|
132
|
+
force_redownload=force_redownload,
|
|
133
|
+
image_indices=image_indices,
|
|
134
|
+
scale=scale,
|
|
135
|
+
dimensions=dimensions,
|
|
136
|
+
)
|
|
137
|
+
response = self._client._http.post("/images", json=body)
|
|
138
|
+
_raise_for_status(response)
|
|
139
|
+
job_id = response.json()["id"]
|
|
140
|
+
self._client._wait_for_job(job_id)
|
|
141
|
+
content = self._client._download_job(job_id)
|
|
142
|
+
return _get.parse_image_download(content)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class AgriGEEClient:
|
|
146
|
+
"""Synchronous client for the AgriGEE.lite REST API. See SPECS.md."""
|
|
147
|
+
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
base_url: str,
|
|
151
|
+
*,
|
|
152
|
+
timeout: float = 60.0,
|
|
153
|
+
poll_interval: float = 1.0,
|
|
154
|
+
poll_timeout: float | None = None,
|
|
155
|
+
) -> None:
|
|
156
|
+
self._http = httpx.Client(base_url=base_url, timeout=timeout)
|
|
157
|
+
self._poll_interval = poll_interval
|
|
158
|
+
self._poll_timeout = poll_timeout
|
|
159
|
+
self._version_checked = False
|
|
160
|
+
self.get = _SyncGet(self)
|
|
161
|
+
|
|
162
|
+
def __enter__(self) -> AgriGEEClient:
|
|
163
|
+
return self
|
|
164
|
+
|
|
165
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
166
|
+
self.close()
|
|
167
|
+
|
|
168
|
+
def close(self) -> None:
|
|
169
|
+
self._http.close()
|
|
170
|
+
|
|
171
|
+
def health(self) -> bool:
|
|
172
|
+
response = self._http.get("/health")
|
|
173
|
+
_raise_for_status(response)
|
|
174
|
+
return response.json().get("status") == "ok"
|
|
175
|
+
|
|
176
|
+
def list_satellites(self) -> list[str]:
|
|
177
|
+
response = self._http.get("/satellites")
|
|
178
|
+
_raise_for_status(response)
|
|
179
|
+
return response.json()
|
|
180
|
+
|
|
181
|
+
def _ensure_server_version_matches(self) -> None:
|
|
182
|
+
"""Checked once per instance, before trusting the embedded satellite
|
|
183
|
+
date table (_satellite_dates.py) — see AgriGEEVersionMismatchError.
|
|
184
|
+
"""
|
|
185
|
+
if self._version_checked:
|
|
186
|
+
return
|
|
187
|
+
response = self._http.get("/version")
|
|
188
|
+
_raise_for_status(response)
|
|
189
|
+
server_version = response.json().get("version")
|
|
190
|
+
if server_version != __version__:
|
|
191
|
+
raise AgriGEEVersionMismatchError(__version__, server_version)
|
|
192
|
+
self._version_checked = True
|
|
193
|
+
|
|
194
|
+
def _wait_for_job(self, job_id: str) -> None:
|
|
195
|
+
elapsed = 0.0
|
|
196
|
+
while True:
|
|
197
|
+
response = self._http.get(f"/jobs/{job_id}")
|
|
198
|
+
_raise_for_status(response)
|
|
199
|
+
job = response.json()
|
|
200
|
+
status = job["status"]
|
|
201
|
+
if status == "completed":
|
|
202
|
+
return
|
|
203
|
+
if status == "failed":
|
|
204
|
+
raise AgriGEEJobError(job_id, job.get("error"))
|
|
205
|
+
if self._poll_timeout is not None and elapsed >= self._poll_timeout:
|
|
206
|
+
raise AgriGEEJobTimeoutError(job_id, self._poll_timeout)
|
|
207
|
+
time.sleep(self._poll_interval)
|
|
208
|
+
elapsed += self._poll_interval
|
|
209
|
+
|
|
210
|
+
def _download_job(self, job_id: str) -> bytes:
|
|
211
|
+
response = self._http.get(f"/jobs/{job_id}/download")
|
|
212
|
+
_raise_for_status(response)
|
|
213
|
+
return response.content
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Exception hierarchy for agrigee_lite_client. See SPECS.md §7."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgriGEEClientError(Exception):
|
|
7
|
+
"""Base class for every error this client raises."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AgriGEEHTTPError(AgriGEEClientError):
|
|
11
|
+
"""The server returned an HTTP error status."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, status_code: int, detail: str) -> None:
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.detail = detail
|
|
16
|
+
super().__init__(f"HTTP {status_code}: {detail}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AgriGEEJobError(AgriGEEClientError):
|
|
20
|
+
"""A submitted job finished with status='failed'."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, job_id: str, error: str | None) -> None:
|
|
23
|
+
self.job_id = job_id
|
|
24
|
+
self.error = error
|
|
25
|
+
super().__init__(f"Job {job_id} failed: {error}")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AgriGEEJobTimeoutError(AgriGEEClientError):
|
|
29
|
+
"""poll_timeout elapsed before the job reached a terminal status."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, job_id: str, poll_timeout: float) -> None:
|
|
32
|
+
self.job_id = job_id
|
|
33
|
+
self.poll_timeout = poll_timeout
|
|
34
|
+
super().__init__(f"Job {job_id} did not finish within {poll_timeout}s")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgriGEEVersionMismatchError(AgriGEEClientError):
|
|
38
|
+
"""The server's agrigee_lite version doesn't match this client's.
|
|
39
|
+
|
|
40
|
+
Raised before trusting the client's embedded satellite date table
|
|
41
|
+
(SPECS.md §5.5) — that table is a static copy of server-side data, only
|
|
42
|
+
guaranteed correct when both sides are on the same version.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, client_version: str, server_version: str | None) -> None:
|
|
46
|
+
self.client_version = client_version
|
|
47
|
+
self.server_version = server_version
|
|
48
|
+
reported = server_version if server_version is not None else "an unknown version (no 'version' in /version)"
|
|
49
|
+
super().__init__(
|
|
50
|
+
f"agrigee_lite_client is version {client_version} but the server is running {reported}. "
|
|
51
|
+
"Install matching versions of both — the client's satellite date table is only valid "
|
|
52
|
+
"when they agree."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AgriGEEUnknownSatelliteError(AgriGEEClientError):
|
|
57
|
+
"""A satellite name isn't in this client's embedded date-range table."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Accepts geopolars.GeoDataFrame, plain polars.DataFrame, or (if installed)
|
|
2
|
+
geopandas.GeoDataFrame as input to multiple_sits — geopandas is never a hard
|
|
3
|
+
dependency of this package (SPECS.md §3): it's detected lazily via
|
|
4
|
+
try/except ImportError, and only actually touched if the caller passes one.
|
|
5
|
+
|
|
6
|
+
geopolars.GeoDataFrame and plain polars.DataFrame already store their
|
|
7
|
+
geometry column as WKB Binary (verified against geopolars 0.1.0-alpha.4 —
|
|
8
|
+
see _geoparquet.py's module docstring), so both go straight to
|
|
9
|
+
build_geoparquet_bytes unchanged; only geopandas input needs converting.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import polars as pl
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _is_geopandas_geodataframe(frame: object) -> bool:
|
|
18
|
+
try:
|
|
19
|
+
import geopandas as gpd
|
|
20
|
+
except ImportError:
|
|
21
|
+
return False
|
|
22
|
+
return isinstance(frame, gpd.GeoDataFrame)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _check_geopandas_crs_is_wgs84(frame) -> None:
|
|
26
|
+
if frame.crs is not None and frame.crs != "EPSG:4326":
|
|
27
|
+
msg = (
|
|
28
|
+
f"Geometry CRS is {frame.crs!r}, but agrigee_lite_client never reprojects "
|
|
29
|
+
"(SPECS.md §1) — reproject to EPSG:4326 yourself before calling multiple_sits/sits, "
|
|
30
|
+
"e.g. `gdf.to_crs('EPSG:4326')`."
|
|
31
|
+
)
|
|
32
|
+
raise ValueError(msg)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_wkb_frame(frame, *, geometry_column: str = "geometry") -> pl.DataFrame:
|
|
36
|
+
"""Normalize any accepted frame type to a plain polars.DataFrame whose
|
|
37
|
+
``geometry_column`` holds WKB bytes, ready for build_geoparquet_bytes.
|
|
38
|
+
"""
|
|
39
|
+
if _is_geopandas_geodataframe(frame):
|
|
40
|
+
import shapely
|
|
41
|
+
|
|
42
|
+
_check_geopandas_crs_is_wgs84(frame)
|
|
43
|
+
wkb = shapely.to_wkb(frame[geometry_column].to_numpy())
|
|
44
|
+
rest = pl.from_pandas(frame.drop(columns=[geometry_column]))
|
|
45
|
+
return rest.with_columns(pl.Series(geometry_column, wkb))
|
|
46
|
+
|
|
47
|
+
return frame
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""GeoParquet encoding/decoding without geopandas. See SPECS.md §5.1.
|
|
2
|
+
|
|
3
|
+
geopolars 0.1.0-alpha.4 does not write GeoParquet file metadata on its own:
|
|
4
|
+
``GeoDataFrame.write_parquet`` is the plain ``polars.DataFrame.write_parquet``
|
|
5
|
+
(inherited, not overridden), and a geometry column round-trips through Arrow
|
|
6
|
+
as a bare ``Binary`` array with no CRS or extension type attached. The server
|
|
7
|
+
decodes uploads with ``geopandas.read_parquet``, which looks for a ``"geo"``
|
|
8
|
+
key in the Parquet file-level metadata
|
|
9
|
+
(https://geoparquet.org/releases/v1.0.0/), so this module builds that key by
|
|
10
|
+
hand — verified end-to-end against ``geopandas.read_parquet`` in
|
|
11
|
+
``tests/test_geoparquet.py``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterable
|
|
19
|
+
|
|
20
|
+
import polars as pl
|
|
21
|
+
import pyarrow.parquet as pq
|
|
22
|
+
import shapely
|
|
23
|
+
from shapely.geometry.base import BaseGeometry
|
|
24
|
+
|
|
25
|
+
_GEOPARQUET_VERSION = "1.0.0"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _geo_metadata(primary_column: str) -> bytes:
|
|
29
|
+
return json.dumps(
|
|
30
|
+
{
|
|
31
|
+
"version": _GEOPARQUET_VERSION,
|
|
32
|
+
"primary_column": primary_column,
|
|
33
|
+
"columns": {
|
|
34
|
+
# geometry_types=[] is valid per spec ("not specified") — this
|
|
35
|
+
# client never introspects per-row geometry types.
|
|
36
|
+
# crs is decorative here: the server takes the authoritative
|
|
37
|
+
# CRS from the `crs` multipart form field, not this metadata
|
|
38
|
+
# (see SPECS.md §5.1). Geometries must already be WGS84.
|
|
39
|
+
primary_column: {"encoding": "WKB", "geometry_types": [], "crs": "OGC:CRS84"},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
).encode()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def encode_geometries(geometries: Iterable[BaseGeometry]) -> list[bytes]:
|
|
46
|
+
"""Serialize shapely geometries to WKB bytes for a Binary parquet column."""
|
|
47
|
+
return [shapely.to_wkb(geometry) for geometry in geometries]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_geoparquet_bytes(frame, *, geometry_column: str = "geometry") -> bytes:
|
|
51
|
+
"""Serialize a geopolars.GeoDataFrame or polars.DataFrame to GeoParquet bytes.
|
|
52
|
+
|
|
53
|
+
``geometry_column`` must already hold WKB bytes (``polars.Binary``) —
|
|
54
|
+
build it with ``encode_geometries`` first.
|
|
55
|
+
"""
|
|
56
|
+
table = frame.to_arrow()
|
|
57
|
+
existing_metadata = table.schema.metadata or {}
|
|
58
|
+
table = table.replace_schema_metadata({**existing_metadata, b"geo": _geo_metadata(geometry_column)})
|
|
59
|
+
buf = io.BytesIO()
|
|
60
|
+
pq.write_table(table, buf)
|
|
61
|
+
return buf.getvalue()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_plain_parquet_bytes(content: bytes) -> pl.DataFrame:
|
|
65
|
+
"""Read a plain (non-geo) parquet response, e.g. a SITS job download."""
|
|
66
|
+
return pl.read_parquet(io.BytesIO(content))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Pure request/response helpers shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
Nothing in this module does I/O — it only builds the bytes/dicts to send and
|
|
4
|
+
parses the bytes that come back. The httpx calls and the job poll loop live
|
|
5
|
+
in ``_client.py`` / ``_async_client.py``, since that's the only part that
|
|
6
|
+
actually needs to differ between sync and async (see SPECS.md §4, §8).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import zipfile
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import polars as pl
|
|
17
|
+
from shapely.geometry import mapping
|
|
18
|
+
from shapely.geometry.base import BaseGeometry
|
|
19
|
+
|
|
20
|
+
from agrigee_lite_client import _geoparquet
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# sits / multiple_sits
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_single_sits_frame(geometry: BaseGeometry, start_date: str, end_date: str) -> pl.DataFrame:
|
|
28
|
+
"""1-row frame used by ``get.sits()`` — see SPECS.md §6 ("naive puro")."""
|
|
29
|
+
wkb = _geoparquet.encode_geometries([geometry])
|
|
30
|
+
return pl.DataFrame({"start_date": [start_date], "end_date": [end_date], "geometry": wkb})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_multiple_sits_upload(
|
|
34
|
+
frame,
|
|
35
|
+
*,
|
|
36
|
+
satellite: str,
|
|
37
|
+
satellite_params: dict[str, Any] | None,
|
|
38
|
+
reducers: list[str] | None,
|
|
39
|
+
start_date_column: str,
|
|
40
|
+
end_date_column: str,
|
|
41
|
+
original_index_column: str,
|
|
42
|
+
subsampling_max_pixels: float,
|
|
43
|
+
chunksize: int,
|
|
44
|
+
max_parallel_downloads: int,
|
|
45
|
+
max_retries_per_chunk: int,
|
|
46
|
+
force_redownload: bool,
|
|
47
|
+
) -> tuple[bytes, dict[str, str]]:
|
|
48
|
+
"""Build the multipart body for ``POST /sits/multiple/file``.
|
|
49
|
+
|
|
50
|
+
Returns ``(parquet_bytes, form_fields)``; ``form_fields`` excludes the
|
|
51
|
+
file itself, which the caller attaches separately as multipart ``file``.
|
|
52
|
+
CRS is always ``EPSG:4326`` — this client never reprojects (SPECS.md §1).
|
|
53
|
+
"""
|
|
54
|
+
parquet_bytes = _geoparquet.build_geoparquet_bytes(frame)
|
|
55
|
+
form: dict[str, str] = {
|
|
56
|
+
"satellite": json.dumps({"name": satellite, "params": satellite_params or {}}),
|
|
57
|
+
"start_date_column": start_date_column,
|
|
58
|
+
"end_date_column": end_date_column,
|
|
59
|
+
"original_index_column": original_index_column,
|
|
60
|
+
"subsampling_max_pixels": str(subsampling_max_pixels),
|
|
61
|
+
"chunksize": str(chunksize),
|
|
62
|
+
"max_parallel_downloads": str(max_parallel_downloads),
|
|
63
|
+
"max_retries_per_chunk": str(max_retries_per_chunk),
|
|
64
|
+
"force_redownload": str(force_redownload).lower(),
|
|
65
|
+
"crs": "EPSG:4326",
|
|
66
|
+
}
|
|
67
|
+
if reducers is not None:
|
|
68
|
+
form["reducers"] = json.dumps(reducers)
|
|
69
|
+
return parquet_bytes, form
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_sits_download(content: bytes) -> pl.DataFrame:
|
|
73
|
+
return _geoparquet.read_plain_parquet_bytes(content)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# image
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_images_request(
|
|
82
|
+
geometry: BaseGeometry,
|
|
83
|
+
*,
|
|
84
|
+
start_date: str,
|
|
85
|
+
end_date: str,
|
|
86
|
+
satellite: str,
|
|
87
|
+
satellite_params: dict[str, Any] | None,
|
|
88
|
+
invalid_images_threshold: float,
|
|
89
|
+
max_parallel_downloads: int,
|
|
90
|
+
force_redownload: bool,
|
|
91
|
+
image_indices: list[int] | None,
|
|
92
|
+
scale: float | None,
|
|
93
|
+
dimensions: int | str | None,
|
|
94
|
+
) -> dict[str, Any]:
|
|
95
|
+
"""JSON body for ``POST /images`` — a single geometry, no parquet needed."""
|
|
96
|
+
return {
|
|
97
|
+
"geometry": mapping(geometry),
|
|
98
|
+
"start_date": start_date,
|
|
99
|
+
"end_date": end_date,
|
|
100
|
+
"satellite": {"name": satellite, "params": satellite_params or {}},
|
|
101
|
+
"invalid_images_threshold": invalid_images_threshold,
|
|
102
|
+
"max_parallel_downloads": max_parallel_downloads,
|
|
103
|
+
"force_redownload": force_redownload,
|
|
104
|
+
"image_indices": image_indices,
|
|
105
|
+
"scale": scale,
|
|
106
|
+
"dimensions": dimensions,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_image_download(content: bytes) -> zipfile.ZipFile:
|
|
111
|
+
return zipfile.ZipFile(io.BytesIO(content))
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Static table of every satellite's temporal range (startDate/endDate).
|
|
2
|
+
|
|
3
|
+
Generated by scripts/generate_satellite_dates.py from the server's own
|
|
4
|
+
agrigee_lite.api._satellites.REGISTRY — do not hand-edit. Regenerate whenever
|
|
5
|
+
a satellite's date range changes or one is added/removed, then bump both
|
|
6
|
+
packages' version together (SPECS.md §2.1); test_satellite_dates_match_server
|
|
7
|
+
catches drift whenever agrigee_lite happens to be importable alongside this
|
|
8
|
+
package (i.e. in this monorepo's dev environment).
|
|
9
|
+
|
|
10
|
+
`TwoSatelliteFusion` is intentionally absent: its constructor takes two
|
|
11
|
+
satellite *objects*, not JSON-serializable params, so it was never reachable
|
|
12
|
+
through the `{name, params}` contract this table (and the REST API's
|
|
13
|
+
SatelliteSpec) is built around.
|
|
14
|
+
|
|
15
|
+
Sentinel2 is the only satellite whose range depends on a constructor param
|
|
16
|
+
(`use_sr`) rather than being fixed per class — handled explicitly in
|
|
17
|
+
get_satellite_date_range() below rather than complicating this table's shape
|
|
18
|
+
for one exception.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from agrigee_lite_client._exceptions import AgriGEEUnknownSatelliteError
|
|
24
|
+
|
|
25
|
+
SATELLITE_DATE_RANGES: dict[str, tuple[str, str]] = {
|
|
26
|
+
'AHN2Interpolated': ('1900-01-01', '2050-01-01'),
|
|
27
|
+
'AHN2NonInterpolated': ('1900-01-01', '2050-01-01'),
|
|
28
|
+
'AHN2Raw': ('1900-01-01', '2050-01-01'),
|
|
29
|
+
'AHN3': ('1900-01-01', '2050-01-01'),
|
|
30
|
+
'AHN4': ('1900-01-01', '2050-01-01'),
|
|
31
|
+
'ALOSWorld3D': ('1900-01-01', '2050-01-01'),
|
|
32
|
+
'ANADEM': ('1900-01-01', '2050-01-01'),
|
|
33
|
+
'ASTERDEM': ('1900-01-01', '2050-01-01'),
|
|
34
|
+
'ArcticDEMMosaic': ('1900-01-01', '2050-01-01'),
|
|
35
|
+
'ArcticDEMStrips': ('1900-01-01', '2050-01-01'),
|
|
36
|
+
'Australia5mDEM': ('1900-01-01', '2050-01-01'),
|
|
37
|
+
'AustraliaDEMH': ('1900-01-01', '2050-01-01'),
|
|
38
|
+
'AustraliaDEMS': ('1900-01-01', '2050-01-01'),
|
|
39
|
+
'CGIARSRTM': ('1900-01-01', '2050-01-01'),
|
|
40
|
+
'CanadaCDEM': ('1900-01-01', '2050-01-01'),
|
|
41
|
+
'CopernicusDEM': ('1900-01-01', '2050-01-01'),
|
|
42
|
+
'CryoSat2Antarctica': ('1900-01-01', '2050-01-01'),
|
|
43
|
+
'ETOPO1': ('1900-01-01', '2050-01-01'),
|
|
44
|
+
'England1mTerrain': ('1900-01-01', '2050-01-01'),
|
|
45
|
+
'FranceRGEAlti': ('1900-01-01', '2050-01-01'),
|
|
46
|
+
'GLOBathy': ('1900-01-01', '2050-01-01'),
|
|
47
|
+
'GMTED2010': ('1900-01-01', '2050-01-01'),
|
|
48
|
+
'GTOPO30': ('1900-01-01', '2050-01-01'),
|
|
49
|
+
'GreenlandGIMP': ('1900-01-01', '2050-01-01'),
|
|
50
|
+
'HLSLandsat': ('2013-04-11', '2050-01-01'),
|
|
51
|
+
'HLSSentinel2': ('2015-11-30', '2050-01-01'),
|
|
52
|
+
'Landsat5': ('1984-03-01', '2013-05-05'),
|
|
53
|
+
'Landsat7': ('1999-04-15', '2022-04-06'),
|
|
54
|
+
'Landsat8': ('2013-04-11', '2050-01-01'),
|
|
55
|
+
'Landsat9': ('2021-11-01', '2050-01-01'),
|
|
56
|
+
'MERITDEM': ('1900-01-01', '2050-01-01'),
|
|
57
|
+
'MapBiomas': ('1985-01-01', '2024-01-01'),
|
|
58
|
+
'Modis8Days': ('2000-02-18', '2050-01-01'),
|
|
59
|
+
'ModisDaily': ('2000-02-24', '2050-01-01'),
|
|
60
|
+
'NAIP': ('2003-01-01', '2050-01-01'),
|
|
61
|
+
'NASADEM': ('1900-01-01', '2050-01-01'),
|
|
62
|
+
'NEONDEM': ('1900-01-01', '2050-01-01'),
|
|
63
|
+
'PALSAR2ScanSAR': ('2014-08-04', '2050-01-01'),
|
|
64
|
+
'PolarisSoilTexture': ('1900-01-01', '2050-01-01'),
|
|
65
|
+
'REMAMosaic': ('1900-01-01', '2050-01-01'),
|
|
66
|
+
'REMAStrips2m': ('1900-01-01', '2050-01-01'),
|
|
67
|
+
'REMAStrips8m': ('1900-01-01', '2050-01-01'),
|
|
68
|
+
'SRTM': ('1900-01-01', '2050-01-01'),
|
|
69
|
+
'SatelliteEmbedding': ('2017-01-01', '2024-01-01'),
|
|
70
|
+
'Sentinel1GRD': ('2014-10-03', '2050-01-01'),
|
|
71
|
+
'Sentinel2': ('2019-01-01', '2050-01-01'),
|
|
72
|
+
'USGS3DEP10m': ('1900-01-01', '2050-01-01'),
|
|
73
|
+
'USGS3DEP1m': ('1900-01-01', '2050-01-01'),
|
|
74
|
+
'WRBSoilClasses': ('1900-01-01', '2050-01-01'),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
_SENTINEL2_USE_SR_FALSE = ("2016-01-01", "2050-01-01")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_satellite_date_range(name: str, params: dict | None = None) -> tuple[str, str]:
|
|
82
|
+
"""(start_date, end_date) for a satellite + params, mirroring the value
|
|
83
|
+
the server's own satellite class would report for the same inputs.
|
|
84
|
+
"""
|
|
85
|
+
if name == "Sentinel2" and params and params.get("use_sr") is False:
|
|
86
|
+
return _SENTINEL2_USE_SR_FALSE
|
|
87
|
+
|
|
88
|
+
date_range = SATELLITE_DATE_RANGES.get(name)
|
|
89
|
+
if date_range is None:
|
|
90
|
+
msg = (
|
|
91
|
+
f"agrigee_lite_client does not know satellite {name!r}. Either it doesn't exist, or this "
|
|
92
|
+
"client's version has drifted from the server's — see SPECS.md §2.1."
|
|
93
|
+
)
|
|
94
|
+
raise AgriGEEUnknownSatelliteError(msg)
|
|
95
|
+
return date_range
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Client-side mirror of the server's date/satellite-range validation.
|
|
2
|
+
|
|
3
|
+
Mirrors agrigee_lite/get/sits.py: sanitize_and_prepare_input_gdf — the masking,
|
|
4
|
+
dropping, and logging around rows whose [start_date, end_date) doesn't
|
|
5
|
+
intersect the satellite's temporal range. Applying this before ever opening a
|
|
6
|
+
connection means an impossible request fails immediately and locally instead
|
|
7
|
+
of round-tripping to the server first (and, for `multiple_sits`, before
|
|
8
|
+
uploading a GeoParquet full of rows the server would just drop anyway).
|
|
9
|
+
|
|
10
|
+
H3 clustering (also part of the server function) is intentionally not
|
|
11
|
+
mirrored here — it's a chunking/performance detail for the server's own
|
|
12
|
+
download scheduling, not part of request validity.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
|
|
20
|
+
import polars as pl
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _as_datetime_expr(frame: pl.DataFrame, column: str) -> pl.Expr:
|
|
26
|
+
"""pl.col(column).cast(pl.Datetime) silently turns every value into null
|
|
27
|
+
for a String column as of polars 1.43 (the cast used to parse; that
|
|
28
|
+
behavior is deprecated/gone, str.to_datetime() is the replacement) — a
|
|
29
|
+
trap the server itself never hits because it always converts date
|
|
30
|
+
columns with pandas.to_datetime() before this kind of check runs, but
|
|
31
|
+
this client hands raw ISO strings straight from sits()/multiple_sits()
|
|
32
|
+
callers.
|
|
33
|
+
"""
|
|
34
|
+
if frame.schema[column] == pl.Utf8:
|
|
35
|
+
return pl.col(column).str.to_datetime(strict=False)
|
|
36
|
+
return pl.col(column).cast(pl.Datetime, strict=False)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def drop_rows_outside_satellite_range(
|
|
40
|
+
frame: pl.DataFrame,
|
|
41
|
+
*,
|
|
42
|
+
satellite_start: str,
|
|
43
|
+
satellite_end: str,
|
|
44
|
+
start_date_column: str,
|
|
45
|
+
end_date_column: str,
|
|
46
|
+
) -> pl.DataFrame:
|
|
47
|
+
"""Drop rows that don't intersect [satellite_start, satellite_end).
|
|
48
|
+
|
|
49
|
+
Raises ValueError if *none* of the rows intersect (mirrors the server's
|
|
50
|
+
behavior exactly). A row with start_date == end_date is dropped too —
|
|
51
|
+
Earth Engine's date filter treats the end date as exclusive, so a
|
|
52
|
+
zero-width window is never valid.
|
|
53
|
+
"""
|
|
54
|
+
working = frame.with_columns(
|
|
55
|
+
_as_datetime_expr(frame, start_date_column).alias(start_date_column),
|
|
56
|
+
_as_datetime_expr(frame, end_date_column).alias(end_date_column),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
sat_start = datetime.fromisoformat(satellite_start)
|
|
60
|
+
sat_end = datetime.fromisoformat(satellite_end)
|
|
61
|
+
|
|
62
|
+
working = working.with_columns(
|
|
63
|
+
(
|
|
64
|
+
(pl.col(end_date_column) < pl.lit(sat_start)) | (pl.col(start_date_column) > pl.lit(sat_end))
|
|
65
|
+
).alias("_mask_no_intersection"),
|
|
66
|
+
(pl.col(start_date_column) == pl.col(end_date_column)).alias("_mask_zero_width"),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
height = working.height
|
|
70
|
+
count_none = int(working.get_column("_mask_no_intersection").sum())
|
|
71
|
+
count_zero_width = int(working.get_column("_mask_zero_width").sum())
|
|
72
|
+
|
|
73
|
+
pct_none = 100 * count_none / height
|
|
74
|
+
if pct_none > 0:
|
|
75
|
+
logger.warning("%.2f%% of the data do not intersect the satellite period.", pct_none)
|
|
76
|
+
|
|
77
|
+
if count_zero_width > 0:
|
|
78
|
+
pct_zero_width = 100 * count_zero_width / height
|
|
79
|
+
logger.warning(
|
|
80
|
+
"%d row(s) (%.2f%%) have start_date == end_date and were dropped: Earth Engine's date "
|
|
81
|
+
"filter treats the end date as exclusive, so a zero-width range is not supported.",
|
|
82
|
+
count_zero_width,
|
|
83
|
+
pct_zero_width,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
if count_none == height:
|
|
87
|
+
msg = f"None of the requested periods intersect the satellite's temporal range ({satellite_start} to {satellite_end})."
|
|
88
|
+
raise ValueError(msg)
|
|
89
|
+
|
|
90
|
+
drop_mask = pl.col("_mask_no_intersection") | pl.col("_mask_zero_width")
|
|
91
|
+
return working.filter(~drop_mask).drop("_mask_no_intersection", "_mask_zero_width")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Single source of truth for this package's version at import time.
|
|
2
|
+
|
|
3
|
+
Kept separate from __init__.py so _client.py/_async_client.py can import it
|
|
4
|
+
without a circular import (they're imported BY __init__.py). Must always
|
|
5
|
+
equal pyproject.toml's version (SPECS.md §2.1) and, transitively, the
|
|
6
|
+
server's — see also the embedded satellite date table in _satellite_dates.py,
|
|
7
|
+
which is only valid when client and server versions agree.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
__version__ = "3.4.1"
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: agrigee_lite_client
|
|
3
|
+
Version: 3.4.1
|
|
4
|
+
Summary: Thin HTTP client for the AgriGEE.lite REST API — no Earth Engine, no GDAL, no DuckDB.
|
|
5
|
+
Project-URL: Homepage, https://mateuspinto.github.io/agrigee_lite/
|
|
6
|
+
Project-URL: Repository, https://github.com/mateuspinto/agrigee_lite
|
|
7
|
+
Project-URL: Documentation, https://mateuspinto.github.io/agrigee_lite/
|
|
8
|
+
Author-email: Mateus Pinto da Silva <mateus.p.silva@ufv.br>
|
|
9
|
+
Keywords: python
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Requires-Python: <4.0,>=3.10
|
|
19
|
+
Requires-Dist: geopolars==0.1.0a4
|
|
20
|
+
Requires-Dist: httpx>=0.27.0
|
|
21
|
+
Requires-Dist: shapely>=2.0.0
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: geopandas>=1.0.1; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: ruff>=0.11.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# agrigee_lite_client
|
|
29
|
+
|
|
30
|
+
Thin HTTP client for the [`agrigee_lite[api]`](../README.md) server. Doesn't
|
|
31
|
+
install Earth Engine, GDAL/PROJ, or DuckDB — it only knows how to build a
|
|
32
|
+
request, upload/download bytes, and talk to `/jobs`. See
|
|
33
|
+
[`SPECS.md`](SPECS.md) for the full design and the reasoning behind it.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install agrigee_lite_client
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
One fixed dependency set, always — `geopolars`, `shapely`, and `httpx`.
|
|
42
|
+
There's no install variation (no `[async]`, `[shapely]`, etc. extras): the
|
|
43
|
+
same install already covers both sync and async use.
|
|
44
|
+
|
|
45
|
+
## Quickstart — sync
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from agrigee_lite_client import AgriGEEClient
|
|
49
|
+
from shapely.geometry import Polygon
|
|
50
|
+
|
|
51
|
+
geometry = Polygon([(-56.42, -11.20), (-56.41, -11.20), (-56.41, -11.19)])
|
|
52
|
+
|
|
53
|
+
with AgriGEEClient("http://192.168.3.204:8100") as client:
|
|
54
|
+
# a single geometry — naive: internally becomes a 1-row multiple_sits
|
|
55
|
+
df = client.get.sits(geometry, "2023-01-01", "2023-06-01", satellite="Sentinel2")
|
|
56
|
+
|
|
57
|
+
# multiple geometries — uploads a GeoParquet, polls the job, downloads the result parquet
|
|
58
|
+
import polars as pl
|
|
59
|
+
from agrigee_lite_client._geoparquet import encode_geometries
|
|
60
|
+
|
|
61
|
+
gdf = pl.DataFrame({
|
|
62
|
+
"start_date": ["2023-01-01", "2023-01-01"],
|
|
63
|
+
"end_date": ["2023-06-01", "2023-06-01"],
|
|
64
|
+
"geometry": encode_geometries([geometry, geometry]),
|
|
65
|
+
})
|
|
66
|
+
df = client.get.multiple_sits(gdf, satellite="Sentinel2", reducers=["mean", "std"])
|
|
67
|
+
|
|
68
|
+
# geopandas.GeoDataFrame also works (geopandas is optional — only
|
|
69
|
+
# imported if you actually pass one; it's not installed alongside the client)
|
|
70
|
+
import geopandas as gpd
|
|
71
|
+
|
|
72
|
+
gdf_geopandas = gpd.GeoDataFrame(
|
|
73
|
+
{"start_date": ["2023-01-01"], "end_date": ["2023-06-01"]},
|
|
74
|
+
geometry=[geometry],
|
|
75
|
+
crs="EPSG:4326",
|
|
76
|
+
)
|
|
77
|
+
df = client.get.multiple_sits(gdf_geopandas, satellite="Sentinel2")
|
|
78
|
+
|
|
79
|
+
# image — returns an in-memory zipfile.ZipFile, nothing decoded
|
|
80
|
+
zf = client.get.image(geometry, "2023-01-01", "2023-06-01", satellite="Sentinel2")
|
|
81
|
+
zf.extractall("out/")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Quickstart — async
|
|
85
|
+
|
|
86
|
+
Same surface, `AsyncAgriGEEClient`:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import asyncio
|
|
90
|
+
from agrigee_lite_client import AsyncAgriGEEClient
|
|
91
|
+
|
|
92
|
+
async def main():
|
|
93
|
+
async with AsyncAgriGEEClient("http://192.168.3.204:8100") as client:
|
|
94
|
+
df = await client.get.sits(geometry, "2023-01-01", "2023-06-01", satellite="Sentinel2")
|
|
95
|
+
|
|
96
|
+
asyncio.run(main())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Validation before anything leaves your machine
|
|
100
|
+
|
|
101
|
+
`sits()`/`multiple_sits()` check that the requested dates actually intersect
|
|
102
|
+
the satellite's operating period — the same check the server does — and
|
|
103
|
+
drop (with a warning) rows that don't, raising `ValueError` if none are
|
|
104
|
+
left. All of this happens **before** building the GeoParquet or opening a
|
|
105
|
+
connection, using a static table embedded in the client (no network call).
|
|
106
|
+
An unknown satellite name is also caught here (`AgriGEEUnknownSatelliteError`).
|
|
107
|
+
|
|
108
|
+
Since that table is a copy of what the server knows, the client checks that
|
|
109
|
+
it's running against the same server version (via `GET /version`) before
|
|
110
|
+
trusting it — a mismatch raises `AgriGEEVersionMismatchError` instead of
|
|
111
|
+
validating against possibly stale data.
|
|
112
|
+
|
|
113
|
+
## What this client does NOT do (on purpose)
|
|
114
|
+
|
|
115
|
+
- No CRS reprojection — input geometries must be in WGS84 (EPSG:4326 /
|
|
116
|
+
OGC:CRS84). If you pass a `geopandas.GeoDataFrame` with a different CRS,
|
|
117
|
+
the client rejects it with a clear error instead of reprojecting.
|
|
118
|
+
- No local caching, no sophisticated retry/backoff — the cache already lives
|
|
119
|
+
on the server; the only "retry" here is `httpx`'s transport timeout and a
|
|
120
|
+
simple poll loop against `GET /jobs/{id}`.
|
|
121
|
+
- No image decoding — `get.image(...)` returns a raw `zipfile.ZipFile`, no
|
|
122
|
+
`numpy`/`tifffile`/`rasterio`.
|
|
123
|
+
- `get.sits(...)` isn't a separate fast path: it's sugar over
|
|
124
|
+
`get.multiple_sits(...)` with a single row, so it pays the job+poll+
|
|
125
|
+
download cost even for one geometry. See `SPECS.md §6`.
|
|
126
|
+
|
|
127
|
+
## Versioning
|
|
128
|
+
|
|
129
|
+
This package's version always matches `agrigee_lite`'s (the server), same
|
|
130
|
+
commit — they aren't versioned independently. A test
|
|
131
|
+
(`tests/test_version_matches_server.py`) enforces this in CI.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
cd agrigee_lite_client
|
|
137
|
+
pip install -e ".[dev]"
|
|
138
|
+
pytest
|
|
139
|
+
ruff check .
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`tests/test_client_against_live_api.py` only runs if `AGRIGEE_TEST_API_URL`
|
|
143
|
+
is set — the rest (`test_client_mocked.py`, `test_async_client_mocked.py`)
|
|
144
|
+
use `httpx.MockTransport` and need no network.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
agrigee_lite_client/__init__.py,sha256=oEDUvBg-I73ld7_8u-1WU875GLe5Wyi_qnAYhKcfzpw,706
|
|
2
|
+
agrigee_lite_client/_async_client.py,sha256=igMn_lCOXt5vYi4inqJfs56h_oBy9uNmYcwGVmsNh30,7600
|
|
3
|
+
agrigee_lite_client/_client.py,sha256=1VkH8Xextw5ieGjrbFpPgH_USvh2PZ73MWsPCOLZjSU,7453
|
|
4
|
+
agrigee_lite_client/_exceptions.py,sha256=SQSZfzbFiEJusWCLI0AJG62cwLJkcgGRaG-jQ5eSrjY,2155
|
|
5
|
+
agrigee_lite_client/_geo_compat.py,sha256=xUBD0aiabpc7D0MCy2d61e2gTMvLQgJzOQnnDbANu6M,1803
|
|
6
|
+
agrigee_lite_client/_geoparquet.py,sha256=r86mnSNU2jcT7vjRc3p1M3qYxVIvOedHMlxIW3i-yTE,2601
|
|
7
|
+
agrigee_lite_client/_get.py,sha256=IqC8EdocfKOgXOplx8xBLUTt-w1sL9PIJieSJWuHHQw,3936
|
|
8
|
+
agrigee_lite_client/_satellite_dates.py,sha256=8Q_ptzrXr9fVPBNHdfFnjrGluAgAS0H1No5vgMExAu8,4326
|
|
9
|
+
agrigee_lite_client/_validation.py,sha256=MtkGH7rbCHNyoH0Co9C2sZnDx1i7SY1fJE2hFdSKdfc,3748
|
|
10
|
+
agrigee_lite_client/_version.py,sha256=VYwI6Af7wusgH04MJHQ-bTxKf7PXDZ6frugNVXU5_KA,456
|
|
11
|
+
agrigee_lite_client-3.4.1.dist-info/METADATA,sha256=3QsQE1q7DrfUHKdQOXC3p7y5bCtVVLRWyIkXHbCBB6c,5632
|
|
12
|
+
agrigee_lite_client-3.4.1.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
13
|
+
agrigee_lite_client-3.4.1.dist-info/RECORD,,
|