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.
@@ -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
+ ]