tilearray 0.1.0__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.
- tilearray/__init__.py +89 -0
- tilearray/_version.py +3 -0
- tilearray/array.py +899 -0
- tilearray/decode.py +353 -0
- tilearray/errors.py +41 -0
- tilearray/fetch.py +772 -0
- tilearray/fetch_presets.py +77 -0
- tilearray/pressure.py +129 -0
- tilearray/py.typed +0 -0
- tilearray/service/__init__.py +33 -0
- tilearray/service/base.py +282 -0
- tilearray/service/config.py +455 -0
- tilearray/service/requests.py +70 -0
- tilearray/service/wcs.py +657 -0
- tilearray/service/wms.py +127 -0
- tilearray/service/wmts.py +590 -0
- tilearray/service/xyz.py +145 -0
- tilearray/tiles.py +142 -0
- tilearray/types.py +235 -0
- tilearray-0.1.0.dist-info/METADATA +345 -0
- tilearray-0.1.0.dist-info/RECORD +23 -0
- tilearray-0.1.0.dist-info/WHEEL +4 -0
- tilearray-0.1.0.dist-info/licenses/LICENSE +201 -0
tilearray/service/wcs.py
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
"""WCS (Web Coverage Service) XML parsing and tile request functionality."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
import xml.etree.ElementTree as ET
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import requests
|
|
12
|
+
|
|
13
|
+
from ..types import (
|
|
14
|
+
CRS,
|
|
15
|
+
BoundingBox,
|
|
16
|
+
CoverageDescription,
|
|
17
|
+
Format,
|
|
18
|
+
ServiceCapabilities,
|
|
19
|
+
ServiceTypeEnum,
|
|
20
|
+
SpatialExtent,
|
|
21
|
+
TemporalExtent,
|
|
22
|
+
TileRequest,
|
|
23
|
+
WCSResponse,
|
|
24
|
+
)
|
|
25
|
+
from .base import BaseService, TileGeometry, register_service
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_OGC_EPSG_URI_RE = re.compile(r"/EPSG/(?:0/)?(\d+)/?$", re.I)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_crs_reference(value: str) -> str:
|
|
33
|
+
"""Normalize OGC URI or EPSG shorthand to ``EPSG:<code>``."""
|
|
34
|
+
|
|
35
|
+
text = value.strip()
|
|
36
|
+
upper = text.upper()
|
|
37
|
+
if upper.startswith("EPSG:"):
|
|
38
|
+
code = text.split(":", 1)[1].split("/")[0]
|
|
39
|
+
return f"EPSG:{code}"
|
|
40
|
+
match = _OGC_EPSG_URI_RE.search(text)
|
|
41
|
+
if match:
|
|
42
|
+
return f"EPSG:{match.group(1)}"
|
|
43
|
+
return text
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _coerce_crs_enum(value: str | None) -> CRS | None:
|
|
47
|
+
if not value:
|
|
48
|
+
return None
|
|
49
|
+
normalized = normalize_crs_reference(value)
|
|
50
|
+
try:
|
|
51
|
+
return CRS(normalized)
|
|
52
|
+
except ValueError:
|
|
53
|
+
try:
|
|
54
|
+
return CRS.from_epsg(normalized)
|
|
55
|
+
except ValueError:
|
|
56
|
+
logger.debug("Skipping unsupported CRS reference '%s'", value)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class WCSParser:
|
|
61
|
+
"""Parser for WCS XML responses."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, base_url: str):
|
|
64
|
+
self.base_url = base_url.rstrip("/")
|
|
65
|
+
self.namespaces = {
|
|
66
|
+
"wcs": "http://www.opengis.net/wcs/2.0",
|
|
67
|
+
"ows": "http://www.opengis.net/ows/1.1",
|
|
68
|
+
"gml": "http://www.opengis.net/gml/3.2",
|
|
69
|
+
"crs": "http://www.opengis.net/wcs/crs/1.0",
|
|
70
|
+
"xsi": "http://www.w3.org/2001/XMLSchema-instance",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def parse_get_capabilities(self, xml_content: str) -> ServiceCapabilities:
|
|
74
|
+
try:
|
|
75
|
+
root = ET.fromstring(xml_content)
|
|
76
|
+
|
|
77
|
+
service_title = self._get_text(
|
|
78
|
+
root, ".//ows:ServiceIdentification/ows:Title"
|
|
79
|
+
)
|
|
80
|
+
service_abstract = self._get_text(
|
|
81
|
+
root, ".//ows:ServiceIdentification/ows:Abstract"
|
|
82
|
+
)
|
|
83
|
+
service_keywords = self._get_keywords(root)
|
|
84
|
+
service_provider = self._get_text(
|
|
85
|
+
root, ".//ows:ServiceProvider/ows:ProviderName"
|
|
86
|
+
)
|
|
87
|
+
service_contact = self._get_text(
|
|
88
|
+
root,
|
|
89
|
+
".//ows:ServiceProvider/ows:ServiceContact/ows:ContactInfo/ows:ContactPersonPrimary/ows:ContactPerson",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
operations: list[str] = []
|
|
93
|
+
for op in root.findall(".//ows:Operation", self.namespaces):
|
|
94
|
+
op_name = op.get("name")
|
|
95
|
+
if op_name:
|
|
96
|
+
operations.append(op_name)
|
|
97
|
+
|
|
98
|
+
supported_formats = self._parse_supported_formats(root)
|
|
99
|
+
supported_crs = self._parse_supported_crs(root)
|
|
100
|
+
coverages = self._parse_coverages(root)
|
|
101
|
+
|
|
102
|
+
return ServiceCapabilities(
|
|
103
|
+
service_title=service_title or "WCS Service",
|
|
104
|
+
service_abstract=service_abstract,
|
|
105
|
+
service_keywords=service_keywords,
|
|
106
|
+
service_provider=service_provider,
|
|
107
|
+
service_contact=service_contact,
|
|
108
|
+
service_url=self.base_url,
|
|
109
|
+
supported_operations=operations,
|
|
110
|
+
supported_formats=supported_formats,
|
|
111
|
+
supported_crs=supported_crs,
|
|
112
|
+
coverages=coverages,
|
|
113
|
+
)
|
|
114
|
+
except ET.ParseError as exc: # pragma: no cover - defensive
|
|
115
|
+
raise ValueError(f"Invalid XML content: {exc}") from exc
|
|
116
|
+
|
|
117
|
+
def parse_describe_coverage(self, xml_content: str) -> CoverageDescription:
|
|
118
|
+
try:
|
|
119
|
+
root = ET.fromstring(xml_content)
|
|
120
|
+
|
|
121
|
+
coverage_elem = root.find(".//wcs:CoverageDescription", self.namespaces)
|
|
122
|
+
if coverage_elem is None and root.tag.lower().endswith(
|
|
123
|
+
"coveragedescription"
|
|
124
|
+
):
|
|
125
|
+
coverage_elem = root
|
|
126
|
+
if coverage_elem is None:
|
|
127
|
+
raise ValueError("No coverage description found in XML")
|
|
128
|
+
|
|
129
|
+
identifier = self._get_text(coverage_elem, ".//gml:identifier")
|
|
130
|
+
if not identifier:
|
|
131
|
+
identifier = self._get_text(coverage_elem, ".//wcs:CoverageId")
|
|
132
|
+
if not identifier:
|
|
133
|
+
raise ValueError("Coverage identifier not found")
|
|
134
|
+
|
|
135
|
+
title = self._get_text(coverage_elem, ".//gml:name")
|
|
136
|
+
abstract = self._get_text(coverage_elem, ".//gml:description")
|
|
137
|
+
keywords = self._get_keywords(coverage_elem)
|
|
138
|
+
supported_crs = self._parse_coverage_crs(coverage_elem)
|
|
139
|
+
supported_formats = self._parse_coverage_formats(coverage_elem)
|
|
140
|
+
spatial_extent = self._parse_spatial_extent(coverage_elem)
|
|
141
|
+
temporal_extent = self._parse_temporal_extent(coverage_elem)
|
|
142
|
+
native_crs, axis_labels = self._parse_envelope_metadata(coverage_elem)
|
|
143
|
+
native_format = self._parse_native_format(coverage_elem)
|
|
144
|
+
|
|
145
|
+
if native_format and native_format not in supported_formats:
|
|
146
|
+
supported_formats.append(native_format)
|
|
147
|
+
if native_crs and native_crs not in supported_crs:
|
|
148
|
+
supported_crs.append(native_crs)
|
|
149
|
+
|
|
150
|
+
return CoverageDescription(
|
|
151
|
+
identifier=identifier,
|
|
152
|
+
title=title,
|
|
153
|
+
abstract=abstract,
|
|
154
|
+
keywords=keywords,
|
|
155
|
+
supported_crs=supported_crs,
|
|
156
|
+
supported_formats=supported_formats,
|
|
157
|
+
spatial_extent=spatial_extent,
|
|
158
|
+
temporal_extent=temporal_extent,
|
|
159
|
+
native_crs=native_crs,
|
|
160
|
+
axis_labels=axis_labels,
|
|
161
|
+
native_format=native_format,
|
|
162
|
+
)
|
|
163
|
+
except ET.ParseError as exc: # pragma: no cover - defensive
|
|
164
|
+
raise ValueError(f"Invalid XML content: {exc}") from exc
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Internal helpers
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
def _get_text(self, element: ET.Element, xpath: str) -> str | None:
|
|
170
|
+
elem = element.find(xpath, self.namespaces)
|
|
171
|
+
return elem.text.strip() if elem is not None and elem.text else None
|
|
172
|
+
|
|
173
|
+
def _get_keywords(self, element: ET.Element) -> list[str]:
|
|
174
|
+
keywords: list[str] = []
|
|
175
|
+
for kw_elem in element.findall(".//ows:Keywords/ows:Keyword", self.namespaces):
|
|
176
|
+
if kw_elem.text:
|
|
177
|
+
keywords.append(kw_elem.text.strip())
|
|
178
|
+
return keywords
|
|
179
|
+
|
|
180
|
+
def _parse_supported_formats(self, root: ET.Element) -> list[Format]:
|
|
181
|
+
formats: list[Format] = []
|
|
182
|
+
for format_elem in root.findall(".//wcs:SupportedFormat", self.namespaces):
|
|
183
|
+
if format_elem.text:
|
|
184
|
+
text = format_elem.text.strip()
|
|
185
|
+
try:
|
|
186
|
+
formats.append(Format(text))
|
|
187
|
+
except ValueError:
|
|
188
|
+
logger.debug("Skipping unsupported WCS format '%s'", text)
|
|
189
|
+
for format_elem in root.findall(".//wcs:formatSupported", self.namespaces):
|
|
190
|
+
if format_elem.text:
|
|
191
|
+
text = format_elem.text.strip()
|
|
192
|
+
try:
|
|
193
|
+
fmt = Format(text)
|
|
194
|
+
except ValueError:
|
|
195
|
+
logger.debug("Skipping unsupported WCS format '%s'", text)
|
|
196
|
+
else:
|
|
197
|
+
if fmt not in formats:
|
|
198
|
+
formats.append(fmt)
|
|
199
|
+
return formats
|
|
200
|
+
|
|
201
|
+
def _parse_supported_crs(self, root: ET.Element) -> list[CRS]:
|
|
202
|
+
crs_list: list[CRS] = []
|
|
203
|
+
for crs_elem in root.findall(".//wcs:SupportedCRS", self.namespaces):
|
|
204
|
+
if crs_elem.text:
|
|
205
|
+
text = crs_elem.text.strip()
|
|
206
|
+
try:
|
|
207
|
+
crs_list.append(CRS(text))
|
|
208
|
+
except ValueError:
|
|
209
|
+
logger.debug("Skipping unsupported CRS '%s'", text)
|
|
210
|
+
for crs_elem in root.findall(".//crs:crsSupported", self.namespaces):
|
|
211
|
+
if crs_elem.text:
|
|
212
|
+
coerced = _coerce_crs_enum(crs_elem.text.strip())
|
|
213
|
+
if coerced is not None and coerced not in crs_list:
|
|
214
|
+
crs_list.append(coerced)
|
|
215
|
+
return crs_list
|
|
216
|
+
|
|
217
|
+
def _parse_coverages(self, root: ET.Element) -> list[CoverageDescription]:
|
|
218
|
+
coverages: list[CoverageDescription] = []
|
|
219
|
+
for coverage_elem in root.findall(
|
|
220
|
+
".//wcs:Contents/wcs:CoverageSummary", self.namespaces
|
|
221
|
+
):
|
|
222
|
+
identifier = self._get_text(coverage_elem, ".//wcs:Identifier")
|
|
223
|
+
if not identifier:
|
|
224
|
+
identifier = self._get_text(coverage_elem, ".//wcs:CoverageId")
|
|
225
|
+
if identifier:
|
|
226
|
+
coverages.append(
|
|
227
|
+
CoverageDescription(
|
|
228
|
+
identifier=identifier,
|
|
229
|
+
title=self._get_text(coverage_elem, ".//wcs:Title"),
|
|
230
|
+
abstract=self._get_text(coverage_elem, ".//wcs:Abstract"),
|
|
231
|
+
keywords=self._get_keywords(coverage_elem),
|
|
232
|
+
native_crs=None,
|
|
233
|
+
native_format=None,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return coverages
|
|
237
|
+
|
|
238
|
+
def _parse_coverage_crs(self, coverage_elem: ET.Element) -> list[CRS]:
|
|
239
|
+
crs_list: list[CRS] = []
|
|
240
|
+
for crs_elem in coverage_elem.findall(".//wcs:SupportedCRS", self.namespaces):
|
|
241
|
+
if crs_elem.text:
|
|
242
|
+
text = crs_elem.text.strip()
|
|
243
|
+
try:
|
|
244
|
+
crs_list.append(CRS(text))
|
|
245
|
+
except ValueError:
|
|
246
|
+
logger.debug("Skipping unsupported CRS '%s'", text)
|
|
247
|
+
return crs_list
|
|
248
|
+
|
|
249
|
+
def _parse_coverage_formats(self, coverage_elem: ET.Element) -> list[Format]:
|
|
250
|
+
formats: list[Format] = []
|
|
251
|
+
for format_elem in coverage_elem.findall(
|
|
252
|
+
".//wcs:SupportedFormat", self.namespaces
|
|
253
|
+
):
|
|
254
|
+
if format_elem.text:
|
|
255
|
+
text = format_elem.text.strip()
|
|
256
|
+
try:
|
|
257
|
+
formats.append(Format(text))
|
|
258
|
+
except ValueError:
|
|
259
|
+
logger.debug("Skipping unsupported format '%s'", text)
|
|
260
|
+
return formats
|
|
261
|
+
|
|
262
|
+
def _find_envelope(self, coverage_elem: ET.Element) -> ET.Element | None:
|
|
263
|
+
envelope = coverage_elem.find(".//gml:boundedBy/gml:Envelope", self.namespaces)
|
|
264
|
+
if envelope is None:
|
|
265
|
+
envelope = coverage_elem.find(".//gml:Envelope", self.namespaces)
|
|
266
|
+
return envelope
|
|
267
|
+
|
|
268
|
+
def _parse_envelope_metadata(
|
|
269
|
+
self, coverage_elem: ET.Element
|
|
270
|
+
) -> tuple[CRS | None, dict[str, tuple[str, str]]]:
|
|
271
|
+
envelope = self._find_envelope(coverage_elem)
|
|
272
|
+
if envelope is None:
|
|
273
|
+
return None, {}
|
|
274
|
+
|
|
275
|
+
srs_name = envelope.get("srsName")
|
|
276
|
+
axis_labels_raw = envelope.get("axisLabels")
|
|
277
|
+
if not srs_name:
|
|
278
|
+
return None, {}
|
|
279
|
+
|
|
280
|
+
normalized_crs = normalize_crs_reference(srs_name)
|
|
281
|
+
native_crs = _coerce_crs_enum(normalized_crs)
|
|
282
|
+
axis_labels: dict[str, tuple[str, str]] = {}
|
|
283
|
+
if axis_labels_raw:
|
|
284
|
+
parts = axis_labels_raw.split()
|
|
285
|
+
if len(parts) >= 2:
|
|
286
|
+
axis_labels[normalized_crs] = (parts[0], parts[1])
|
|
287
|
+
return native_crs, axis_labels
|
|
288
|
+
|
|
289
|
+
def _parse_native_format(self, coverage_elem: ET.Element) -> Format | None:
|
|
290
|
+
native_format_elem = coverage_elem.find(
|
|
291
|
+
".//wcs:ServiceParameters/wcs:nativeFormat", self.namespaces
|
|
292
|
+
)
|
|
293
|
+
if native_format_elem is None or not native_format_elem.text:
|
|
294
|
+
return None
|
|
295
|
+
text = native_format_elem.text.strip()
|
|
296
|
+
try:
|
|
297
|
+
return Format(text)
|
|
298
|
+
except ValueError:
|
|
299
|
+
logger.debug("Skipping unsupported native format '%s'", text)
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
def _parse_spatial_extent(self, coverage_elem: ET.Element) -> SpatialExtent | None:
|
|
303
|
+
envelope = self._find_envelope(coverage_elem)
|
|
304
|
+
if envelope is None:
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
lower_corner = envelope.find(".//gml:lowerCorner", self.namespaces)
|
|
308
|
+
upper_corner = envelope.find(".//gml:upperCorner", self.namespaces)
|
|
309
|
+
if not (lower_corner is not None and upper_corner is not None):
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
lower_text = (lower_corner.text or "").strip()
|
|
313
|
+
upper_text = (upper_corner.text or "").strip()
|
|
314
|
+
if not lower_text or not upper_text:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
lower_coords = [float(x) for x in lower_text.split()]
|
|
319
|
+
upper_coords = [float(x) for x in upper_text.split()]
|
|
320
|
+
except (AttributeError, ValueError):
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
if len(lower_coords) < 2 or len(upper_coords) < 2:
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
srs_name = envelope.get("srsName")
|
|
327
|
+
bbox_crs = _coerce_crs_enum(normalize_crs_reference(srs_name or ""))
|
|
328
|
+
if bbox_crs is None:
|
|
329
|
+
bbox_crs = self._parse_native_crs(coverage_elem)
|
|
330
|
+
|
|
331
|
+
bbox = BoundingBox(
|
|
332
|
+
min_x=lower_coords[0],
|
|
333
|
+
min_y=lower_coords[1],
|
|
334
|
+
max_x=upper_coords[0],
|
|
335
|
+
max_y=upper_coords[1],
|
|
336
|
+
crs=bbox_crs,
|
|
337
|
+
)
|
|
338
|
+
return SpatialExtent(bbox=bbox, dimensions=None)
|
|
339
|
+
|
|
340
|
+
def _parse_temporal_extent(
|
|
341
|
+
self, coverage_elem: ET.Element
|
|
342
|
+
) -> TemporalExtent | None:
|
|
343
|
+
time_elem = coverage_elem.find(".//gml:TimePeriod", self.namespaces)
|
|
344
|
+
if time_elem is None:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
begin_elem = time_elem.find(".//gml:beginPosition", self.namespaces)
|
|
348
|
+
end_elem = time_elem.find(".//gml:endPosition", self.namespaces)
|
|
349
|
+
|
|
350
|
+
start_time = self._parse_datetime(
|
|
351
|
+
begin_elem.text if begin_elem is not None else None
|
|
352
|
+
)
|
|
353
|
+
end_time = self._parse_datetime(end_elem.text if end_elem is not None else None)
|
|
354
|
+
|
|
355
|
+
if start_time or end_time:
|
|
356
|
+
return TemporalExtent(start_time=start_time, end_time=end_time)
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
def _parse_datetime(self, value: str | None) -> datetime | None:
|
|
360
|
+
if not value:
|
|
361
|
+
return None
|
|
362
|
+
try:
|
|
363
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
364
|
+
except ValueError:
|
|
365
|
+
logger.debug("Failed to parse datetime '%s'", value)
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
def _parse_native_crs(self, coverage_elem: ET.Element) -> CRS:
|
|
369
|
+
native_crs, _ = self._parse_envelope_metadata(coverage_elem)
|
|
370
|
+
if native_crs is not None:
|
|
371
|
+
return native_crs
|
|
372
|
+
|
|
373
|
+
native_crs_elem = coverage_elem.find(".//wcs:NativeCRS", self.namespaces)
|
|
374
|
+
if native_crs_elem is not None and native_crs_elem.text:
|
|
375
|
+
coerced = _coerce_crs_enum(native_crs_elem.text.strip())
|
|
376
|
+
if coerced is not None:
|
|
377
|
+
return coerced
|
|
378
|
+
return CRS.EPSG_4326
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
@register_service(ServiceTypeEnum.WCS)
|
|
382
|
+
class WCSService(BaseService):
|
|
383
|
+
"""Client for interacting with WCS endpoints."""
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
base_url: str,
|
|
388
|
+
*,
|
|
389
|
+
version: str = "2.0.1",
|
|
390
|
+
session: requests.Session | None = None,
|
|
391
|
+
coverage_id: str | None = None,
|
|
392
|
+
output_format: Format | None = None,
|
|
393
|
+
crs: CRS | None = None,
|
|
394
|
+
**config: Any,
|
|
395
|
+
) -> None:
|
|
396
|
+
super().__init__(base_url, version=version, **config)
|
|
397
|
+
self.session = session or requests.Session()
|
|
398
|
+
self.version = version
|
|
399
|
+
self.coverage_id = coverage_id or config.get("layer_id")
|
|
400
|
+
self.parser = WCSParser(self.base_url)
|
|
401
|
+
self.output_format = self._coerce_format(
|
|
402
|
+
output_format or config.get("format") or Format.GEOTIFF
|
|
403
|
+
)
|
|
404
|
+
self.subsetting_crs = self._coerce_crs(
|
|
405
|
+
crs or config.get("crs") or CRS.EPSG_4326
|
|
406
|
+
)
|
|
407
|
+
self._coverage_metadata: dict[str, CoverageDescription] = {}
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def from_url(cls, url: str, **config: Any) -> WCSService:
|
|
411
|
+
return cls(url, **config)
|
|
412
|
+
|
|
413
|
+
def set_coverage_metadata(self, description: CoverageDescription) -> None:
|
|
414
|
+
"""Cache DescribeCoverage metadata for GetCoverage request building."""
|
|
415
|
+
|
|
416
|
+
self._coverage_metadata[description.identifier] = description
|
|
417
|
+
|
|
418
|
+
def coverage_metadata(
|
|
419
|
+
self, coverage_id: str | None = None
|
|
420
|
+
) -> CoverageDescription | None:
|
|
421
|
+
coverage = coverage_id or self.coverage_id
|
|
422
|
+
if not coverage:
|
|
423
|
+
return None
|
|
424
|
+
return self._coverage_metadata.get(coverage)
|
|
425
|
+
|
|
426
|
+
def ensure_coverage_metadata(
|
|
427
|
+
self, coverage_id: str | None = None, **params: Any
|
|
428
|
+
) -> CoverageDescription:
|
|
429
|
+
coverage = coverage_id or self._require_coverage_id()
|
|
430
|
+
cached = self._coverage_metadata.get(coverage)
|
|
431
|
+
if cached is not None:
|
|
432
|
+
return cached
|
|
433
|
+
description = self.describe_coverage(coverage, **params)
|
|
434
|
+
self.set_coverage_metadata(description)
|
|
435
|
+
return description
|
|
436
|
+
|
|
437
|
+
# ------------------------------------------------------------------
|
|
438
|
+
# Public API
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
def get_capabilities(self, **params: Any) -> ServiceCapabilities:
|
|
441
|
+
response = self.session.get(
|
|
442
|
+
self.base_url,
|
|
443
|
+
params={
|
|
444
|
+
"service": "WCS",
|
|
445
|
+
"version": self.version,
|
|
446
|
+
"request": "GetCapabilities",
|
|
447
|
+
**params,
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
response.raise_for_status()
|
|
451
|
+
return self.parser.parse_get_capabilities(response.text)
|
|
452
|
+
|
|
453
|
+
def describe_coverage(
|
|
454
|
+
self, coverage_id: str | None = None, **params: Any
|
|
455
|
+
) -> CoverageDescription:
|
|
456
|
+
coverage = coverage_id or self._require_coverage_id()
|
|
457
|
+
response = self.session.get(
|
|
458
|
+
self.base_url,
|
|
459
|
+
params={
|
|
460
|
+
"service": "WCS",
|
|
461
|
+
"version": self.version,
|
|
462
|
+
"request": "DescribeCoverage",
|
|
463
|
+
"coverageId": coverage,
|
|
464
|
+
**params,
|
|
465
|
+
},
|
|
466
|
+
)
|
|
467
|
+
response.raise_for_status()
|
|
468
|
+
description = self.parser.parse_describe_coverage(response.text)
|
|
469
|
+
self.set_coverage_metadata(description)
|
|
470
|
+
return description
|
|
471
|
+
|
|
472
|
+
def get_coverage(
|
|
473
|
+
self,
|
|
474
|
+
coverage_id: str | None,
|
|
475
|
+
bbox: BoundingBox,
|
|
476
|
+
width: int,
|
|
477
|
+
height: int,
|
|
478
|
+
*,
|
|
479
|
+
output_format: Format | None = None,
|
|
480
|
+
crs: CRS | None = None,
|
|
481
|
+
**params: Any,
|
|
482
|
+
) -> WCSResponse:
|
|
483
|
+
coverage = coverage_id or self._require_coverage_id()
|
|
484
|
+
metadata = self.ensure_coverage_metadata(coverage)
|
|
485
|
+
fmt = self._coerce_format(
|
|
486
|
+
output_format
|
|
487
|
+
or self.output_format
|
|
488
|
+
or metadata.native_format
|
|
489
|
+
or Format.GEOTIFF
|
|
490
|
+
)
|
|
491
|
+
subset_crs, subset_bbox, subset_parts = self._resolve_subset(
|
|
492
|
+
bbox, crs or self.subsetting_crs, metadata
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
request_params = {
|
|
496
|
+
"service": "WCS",
|
|
497
|
+
"version": self.version,
|
|
498
|
+
"request": "GetCoverage",
|
|
499
|
+
"coverageId": coverage,
|
|
500
|
+
"subset": subset_parts,
|
|
501
|
+
"format": fmt.value,
|
|
502
|
+
"width": str(width),
|
|
503
|
+
"height": str(height),
|
|
504
|
+
"subsettingCRS": subset_crs.value,
|
|
505
|
+
**params,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
try:
|
|
509
|
+
response = self.session.get(self.base_url, params=request_params)
|
|
510
|
+
response.raise_for_status()
|
|
511
|
+
return WCSResponse(
|
|
512
|
+
success=True,
|
|
513
|
+
data=response.content,
|
|
514
|
+
error_message=None,
|
|
515
|
+
status_code=response.status_code,
|
|
516
|
+
)
|
|
517
|
+
except requests.RequestException as exc:
|
|
518
|
+
logger.debug("WCS GetCoverage failed: %s", exc, exc_info=True)
|
|
519
|
+
status_code = exc.response.status_code if exc.response is not None else None
|
|
520
|
+
return WCSResponse(
|
|
521
|
+
success=False,
|
|
522
|
+
data=None,
|
|
523
|
+
error_message=str(exc),
|
|
524
|
+
status_code=status_code,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
# BaseService overrides
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
def build_tile_request(self, tile: TileGeometry, **options: Any) -> TileRequest:
|
|
531
|
+
coverage = options.get("coverage_id") or self.coverage_id
|
|
532
|
+
if not coverage:
|
|
533
|
+
raise ValueError("WCS coverage_id must be provided")
|
|
534
|
+
|
|
535
|
+
metadata = self._metadata_or_stub(coverage)
|
|
536
|
+
fmt = self._coerce_format(
|
|
537
|
+
options.get("output_format") or self.output_format or metadata.native_format
|
|
538
|
+
)
|
|
539
|
+
requested_crs = self._coerce_crs(options.get("crs") or tile.crs)
|
|
540
|
+
subset_crs, subset_bbox, subset_parts = self._resolve_subset(
|
|
541
|
+
tile.bbox, requested_crs, metadata
|
|
542
|
+
)
|
|
543
|
+
subset_tile = TileGeometry(
|
|
544
|
+
bbox=subset_bbox,
|
|
545
|
+
width=tile.width,
|
|
546
|
+
height=tile.height,
|
|
547
|
+
crs=subset_crs,
|
|
548
|
+
tile_x=tile.tile_x,
|
|
549
|
+
tile_y=tile.tile_y,
|
|
550
|
+
zoom=tile.zoom,
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
params: dict[str, Any] = {
|
|
554
|
+
"service": "WCS",
|
|
555
|
+
"version": self.version,
|
|
556
|
+
"request": "GetCoverage",
|
|
557
|
+
"coverageId": coverage,
|
|
558
|
+
"subset": subset_parts,
|
|
559
|
+
"format": fmt.value,
|
|
560
|
+
"width": str(tile.width),
|
|
561
|
+
"height": str(tile.height),
|
|
562
|
+
"subsettingCRS": subset_crs.value,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
passthrough = {
|
|
566
|
+
key: value
|
|
567
|
+
for key, value in options.items()
|
|
568
|
+
if key not in {"output_format", "crs"}
|
|
569
|
+
}
|
|
570
|
+
return self.compose_tile_request(
|
|
571
|
+
subset_tile,
|
|
572
|
+
url=self.base_url,
|
|
573
|
+
params=params,
|
|
574
|
+
output_format=fmt,
|
|
575
|
+
crs=subset_crs,
|
|
576
|
+
**passthrough,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
# Helpers
|
|
581
|
+
# ------------------------------------------------------------------
|
|
582
|
+
def _require_coverage_id(self) -> str:
|
|
583
|
+
if not self.coverage_id:
|
|
584
|
+
raise ValueError("WCS coverage_id is required but was not provided")
|
|
585
|
+
return self.coverage_id
|
|
586
|
+
|
|
587
|
+
def _metadata_or_stub(self, coverage_id: str) -> CoverageDescription:
|
|
588
|
+
cached = self.coverage_metadata(coverage_id)
|
|
589
|
+
if cached is not None:
|
|
590
|
+
return cached
|
|
591
|
+
return CoverageDescription(
|
|
592
|
+
identifier=coverage_id,
|
|
593
|
+
native_crs=None,
|
|
594
|
+
native_format=None,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def _coerce_format(self, fmt: Any) -> Format:
|
|
598
|
+
if isinstance(fmt, Format):
|
|
599
|
+
return fmt
|
|
600
|
+
if isinstance(fmt, str):
|
|
601
|
+
try:
|
|
602
|
+
return Format(fmt)
|
|
603
|
+
except ValueError as exc:
|
|
604
|
+
raise ValueError(f"Unsupported WCS format: {fmt}") from exc
|
|
605
|
+
raise ValueError(f"Invalid WCS format value: {fmt!r}")
|
|
606
|
+
|
|
607
|
+
def _coerce_crs(self, crs: Any) -> CRS:
|
|
608
|
+
if isinstance(crs, CRS):
|
|
609
|
+
return crs
|
|
610
|
+
if isinstance(crs, str):
|
|
611
|
+
return (
|
|
612
|
+
CRS.from_epsg(crs)
|
|
613
|
+
if crs.upper().startswith("EPSG:")
|
|
614
|
+
else CRS.from_integer(int(crs))
|
|
615
|
+
)
|
|
616
|
+
if isinstance(crs, int):
|
|
617
|
+
return CRS.from_integer(crs)
|
|
618
|
+
raise ValueError(f"Invalid CRS value: {crs!r}")
|
|
619
|
+
|
|
620
|
+
def _resolve_subset(
|
|
621
|
+
self,
|
|
622
|
+
bbox: BoundingBox,
|
|
623
|
+
requested_crs: CRS,
|
|
624
|
+
metadata: CoverageDescription,
|
|
625
|
+
) -> tuple[CRS, BoundingBox, list[str]]:
|
|
626
|
+
subset_crs = requested_crs
|
|
627
|
+
subset_bbox = bbox if bbox.crs == requested_crs else bbox.to_crs(requested_crs)
|
|
628
|
+
axis_labels = metadata.axis_labels.get(requested_crs.value)
|
|
629
|
+
|
|
630
|
+
if axis_labels is None and metadata.native_crs is not None:
|
|
631
|
+
native_key = metadata.native_crs.value
|
|
632
|
+
native_axes = metadata.axis_labels.get(native_key)
|
|
633
|
+
if native_axes is not None:
|
|
634
|
+
subset_crs = metadata.native_crs
|
|
635
|
+
subset_bbox = subset_bbox.to_crs(subset_crs)
|
|
636
|
+
axis_labels = native_axes
|
|
637
|
+
|
|
638
|
+
if axis_labels is None:
|
|
639
|
+
axis_labels = self._default_subset_axes(subset_crs)
|
|
640
|
+
|
|
641
|
+
return subset_crs, subset_bbox, self._format_subset(subset_bbox, axis_labels)
|
|
642
|
+
|
|
643
|
+
def _default_subset_axes(self, crs: CRS) -> tuple[str, str]:
|
|
644
|
+
if crs == CRS.EPSG_27700:
|
|
645
|
+
return ("E", "N")
|
|
646
|
+
if crs == CRS.EPSG_3857:
|
|
647
|
+
return ("x", "y")
|
|
648
|
+
return ("Long", "Lat")
|
|
649
|
+
|
|
650
|
+
def _format_subset(
|
|
651
|
+
self, bbox: BoundingBox, axis_labels: tuple[str, str]
|
|
652
|
+
) -> list[str]:
|
|
653
|
+
axis_x, axis_y = axis_labels
|
|
654
|
+
return [
|
|
655
|
+
f"{axis_x}({bbox.min_x},{bbox.max_x})",
|
|
656
|
+
f"{axis_y}({bbox.min_y},{bbox.max_y})",
|
|
657
|
+
]
|