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/xyz.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""XYZ (slippy map) tile service implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..types import CRS, BoundingBox, Format, ServiceTypeEnum, TileRequest
|
|
10
|
+
from .base import BaseService, TileGeometry, register_service
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@register_service(ServiceTypeEnum.XYZ)
|
|
14
|
+
class XYZService(BaseService):
|
|
15
|
+
"""Client for XYZ / slippy-map tile endpoints."""
|
|
16
|
+
|
|
17
|
+
inferred_grid_shape: tuple[int, int] | None = None
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
base_url: str,
|
|
22
|
+
*,
|
|
23
|
+
zoom: int,
|
|
24
|
+
tile_size: int = 256,
|
|
25
|
+
output_format: Format | None = Format.PNG,
|
|
26
|
+
crs: CRS | None = CRS.EPSG_4326,
|
|
27
|
+
**config: Any,
|
|
28
|
+
) -> None:
|
|
29
|
+
super().__init__(base_url, zoom=zoom, tile_size=tile_size, **config)
|
|
30
|
+
if zoom < 0:
|
|
31
|
+
raise ValueError("zoom must be non-negative")
|
|
32
|
+
if tile_size <= 0:
|
|
33
|
+
raise ValueError("tile_size must be positive")
|
|
34
|
+
self.url_template = base_url
|
|
35
|
+
self.zoom = zoom
|
|
36
|
+
self.tile_size = tile_size
|
|
37
|
+
self.output_format = output_format or Format.PNG
|
|
38
|
+
self.native_crs = crs or CRS.EPSG_4326
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_url(cls, url: str, **config: Any) -> XYZService:
|
|
42
|
+
zoom = config.get("zoom")
|
|
43
|
+
if zoom is None:
|
|
44
|
+
raise ValueError("XYZ services require a zoom level")
|
|
45
|
+
return cls(url, **config)
|
|
46
|
+
|
|
47
|
+
def plan_tiles(
|
|
48
|
+
self,
|
|
49
|
+
bbox: BoundingBox,
|
|
50
|
+
chunk_size: tuple[int, int],
|
|
51
|
+
**options: object,
|
|
52
|
+
) -> Iterable[TileGeometry]:
|
|
53
|
+
zoom_value = options.get("zoom", self.zoom)
|
|
54
|
+
tile_size_value = options.get("tile_size", self.tile_size)
|
|
55
|
+
zoom = int(zoom_value) if isinstance(zoom_value, (int, str)) else self.zoom
|
|
56
|
+
tile_size = (
|
|
57
|
+
int(tile_size_value)
|
|
58
|
+
if isinstance(tile_size_value, (int, str))
|
|
59
|
+
else self.tile_size
|
|
60
|
+
)
|
|
61
|
+
target_crs = options.get("crs", self.native_crs)
|
|
62
|
+
if not isinstance(target_crs, CRS):
|
|
63
|
+
target_crs = self.native_crs
|
|
64
|
+
|
|
65
|
+
working_bbox = bbox if bbox.crs == CRS.EPSG_4326 else bbox.to_crs(CRS.EPSG_4326)
|
|
66
|
+
x_min, y_min, x_max, y_max = _tile_range_for_bbox(working_bbox, zoom)
|
|
67
|
+
|
|
68
|
+
rows = y_max - y_min + 1
|
|
69
|
+
cols = x_max - x_min + 1
|
|
70
|
+
self.inferred_grid_shape = (rows, cols)
|
|
71
|
+
|
|
72
|
+
for tile_y in range(y_min, y_max + 1):
|
|
73
|
+
for tile_x in range(x_min, x_max + 1):
|
|
74
|
+
tile_bbox = _tile_bounds(tile_x, tile_y, zoom, target_crs)
|
|
75
|
+
yield TileGeometry(
|
|
76
|
+
bbox=tile_bbox,
|
|
77
|
+
width=tile_size,
|
|
78
|
+
height=tile_size,
|
|
79
|
+
crs=target_crs,
|
|
80
|
+
tile_x=tile_x,
|
|
81
|
+
tile_y=tile_y,
|
|
82
|
+
zoom=zoom,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def build_tile_request(self, tile: TileGeometry, **options: Any) -> TileRequest:
|
|
86
|
+
tile_x = _require_tile_index(tile.tile_x, options.get("x"))
|
|
87
|
+
tile_y = _require_tile_index(tile.tile_y, options.get("y"))
|
|
88
|
+
zoom = int(
|
|
89
|
+
options.get("zoom", tile.zoom if tile.zoom is not None else self.zoom)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
url = self.url_template.format(x=tile_x, y=tile_y, z=zoom)
|
|
93
|
+
fmt = options.get("output_format") or self.output_format
|
|
94
|
+
if isinstance(fmt, str):
|
|
95
|
+
fmt = Format(fmt)
|
|
96
|
+
|
|
97
|
+
passthrough = {
|
|
98
|
+
key: value for key, value in options.items() if key != "output_format"
|
|
99
|
+
}
|
|
100
|
+
return self.compose_tile_request(
|
|
101
|
+
tile,
|
|
102
|
+
url=url,
|
|
103
|
+
output_format=fmt,
|
|
104
|
+
**passthrough,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_tile_index(tile_index: int | None, override: int | None) -> int:
|
|
109
|
+
value = override if override is not None else tile_index
|
|
110
|
+
if value is None:
|
|
111
|
+
raise ValueError("XYZ tile indices are required")
|
|
112
|
+
return int(value)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _tile_range_for_bbox(bbox: BoundingBox, zoom: int) -> tuple[int, int, int, int]:
|
|
116
|
+
x_min = _lon_to_tile_x(bbox.min_x, zoom)
|
|
117
|
+
x_max = _lon_to_tile_x(bbox.max_x, zoom)
|
|
118
|
+
y_min = _lat_to_tile_y(bbox.max_y, zoom)
|
|
119
|
+
y_max = _lat_to_tile_y(bbox.min_y, zoom)
|
|
120
|
+
return x_min, y_min, x_max, y_max
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _lon_to_tile_x(lon: float, zoom: int) -> int:
|
|
124
|
+
scale = 2**zoom
|
|
125
|
+
return int(math.floor((lon + 180.0) / 360.0 * scale))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _lat_to_tile_y(lat: float, zoom: int) -> int:
|
|
129
|
+
scale = 2**zoom
|
|
130
|
+
lat_rad = math.radians(lat)
|
|
131
|
+
return int(
|
|
132
|
+
math.floor((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * scale)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _tile_bounds(x: int, y: int, zoom: int, crs: CRS) -> BoundingBox:
|
|
137
|
+
scale = 2**zoom
|
|
138
|
+
min_x = x / scale * 360.0 - 180.0
|
|
139
|
+
max_x = (x + 1) / scale * 360.0 - 180.0
|
|
140
|
+
max_y = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * y / scale))))
|
|
141
|
+
min_y = math.degrees(math.atan(math.sinh(math.pi * (1 - 2 * (y + 1) / scale))))
|
|
142
|
+
bbox = BoundingBox(
|
|
143
|
+
min_x=min_x, min_y=min_y, max_x=max_x, max_y=max_y, crs=CRS.EPSG_4326
|
|
144
|
+
)
|
|
145
|
+
return bbox if crs == CRS.EPSG_4326 else bbox.to_crs(crs)
|
tilearray/tiles.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic tile fetching functionality for geospatial services.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import math
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from .fetch import (
|
|
14
|
+
FetchPolicy,
|
|
15
|
+
FetchProgress,
|
|
16
|
+
TileFetcher,
|
|
17
|
+
fetch_tile_with_policy,
|
|
18
|
+
get_fetcher,
|
|
19
|
+
)
|
|
20
|
+
from .types import BoundingBox, TileRequest, TileResponse
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"FetchPolicy",
|
|
26
|
+
"TileFetcher",
|
|
27
|
+
"create_tile_grid",
|
|
28
|
+
"fetch_tile",
|
|
29
|
+
"get_fetcher",
|
|
30
|
+
"save_tile",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_tile(
|
|
35
|
+
request: TileRequest,
|
|
36
|
+
*,
|
|
37
|
+
policy: FetchPolicy | None = None,
|
|
38
|
+
fetcher: TileFetcher | None = None,
|
|
39
|
+
progress: FetchProgress | None = None,
|
|
40
|
+
) -> TileResponse:
|
|
41
|
+
"""
|
|
42
|
+
Fetch a tile from any geospatial service.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
request: Tile request parameters
|
|
46
|
+
policy: Optional fetch policy (concurrency, retries, rate limits)
|
|
47
|
+
fetcher: Optional explicit fetcher instance
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Tile response with data or error information
|
|
51
|
+
"""
|
|
52
|
+
if fetcher is not None:
|
|
53
|
+
return fetcher.fetch(request, progress=progress)
|
|
54
|
+
return fetch_tile_with_policy(request, policy, progress=progress)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_tile(tile_response: TileResponse, output_path: str | Path) -> bool:
|
|
58
|
+
"""
|
|
59
|
+
Save tile data to file.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
tile_response: Response from tile request
|
|
63
|
+
output_path: Path to save the tile
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True if successful, False otherwise
|
|
67
|
+
"""
|
|
68
|
+
if not tile_response.success:
|
|
69
|
+
logger.error(f"Cannot save failed tile: {tile_response.error_message}")
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
output_path = Path(output_path)
|
|
74
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
|
|
76
|
+
with open(output_path, "wb") as f:
|
|
77
|
+
f.write(tile_response.data)
|
|
78
|
+
|
|
79
|
+
logger.debug(f"Saved tile to {output_path}")
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.error(f"Failed to save tile to {output_path}: {e}")
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def create_tile_grid(
|
|
88
|
+
bbox: BoundingBox,
|
|
89
|
+
tile_size: tuple[int, int],
|
|
90
|
+
origin: tuple[float, float],
|
|
91
|
+
resolution: tuple[float, float],
|
|
92
|
+
) -> np.ndarray:
|
|
93
|
+
"""
|
|
94
|
+
Create a grid of tiles covering the bounding box with the given tile size, origin and resolution.
|
|
95
|
+
|
|
96
|
+
Tiles are aligned to a fixed grid anchored at ``origin``. Each tile spans
|
|
97
|
+
``tile_size`` pixels at the given ground resolution. When the bounding box
|
|
98
|
+
is not grid-aligned, the returned tiles extend beyond the bbox so their
|
|
99
|
+
union fully covers it.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
bbox: Overall bounding box
|
|
103
|
+
tile_size: Size of each tile in pixels as ``(width, height)``
|
|
104
|
+
origin: Origin of the grid in the given CRS as ``(x, y)``
|
|
105
|
+
resolution: Ground resolution as ``(x, y)`` units per pixel
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
``float64`` array with shape ``(n_rows, n_cols, 4)`` where the last
|
|
109
|
+
dimension stores ``(min_x, min_y, max_x, max_y)`` for each tile.
|
|
110
|
+
Rows increase with ``y`` (bottom row first).
|
|
111
|
+
"""
|
|
112
|
+
tile_width, tile_height = tile_size
|
|
113
|
+
res_x, res_y = resolution
|
|
114
|
+
if tile_width <= 0 or tile_height <= 0:
|
|
115
|
+
raise ValueError("tile_size dimensions must be positive")
|
|
116
|
+
if res_x <= 0 or res_y <= 0:
|
|
117
|
+
raise ValueError("resolution values must be positive")
|
|
118
|
+
|
|
119
|
+
origin_x, origin_y = origin
|
|
120
|
+
tile_width_units = tile_width * res_x
|
|
121
|
+
tile_height_units = tile_height * res_y
|
|
122
|
+
|
|
123
|
+
col_min = math.floor((bbox.min_x - origin_x) / tile_width_units)
|
|
124
|
+
col_max = math.ceil((bbox.max_x - origin_x) / tile_width_units) - 1
|
|
125
|
+
row_min = math.floor((bbox.min_y - origin_y) / tile_height_units)
|
|
126
|
+
row_max = math.ceil((bbox.max_y - origin_y) / tile_height_units) - 1
|
|
127
|
+
|
|
128
|
+
n_cols = col_max - col_min + 1
|
|
129
|
+
n_rows = row_max - row_min + 1
|
|
130
|
+
if n_cols <= 0 or n_rows <= 0:
|
|
131
|
+
return np.empty((0, 0, 4), dtype=np.float64)
|
|
132
|
+
|
|
133
|
+
grid = np.empty((n_rows, n_cols, 4), dtype=np.float64)
|
|
134
|
+
for row_index, row in enumerate(range(row_min, row_max + 1)):
|
|
135
|
+
min_y = origin_y + row * tile_height_units
|
|
136
|
+
max_y = min_y + tile_height_units
|
|
137
|
+
for col_index, col in enumerate(range(col_min, col_max + 1)):
|
|
138
|
+
min_x = origin_x + col * tile_width_units
|
|
139
|
+
max_x = min_x + tile_width_units
|
|
140
|
+
grid[row_index, col_index] = (min_x, min_y, max_x, max_y)
|
|
141
|
+
|
|
142
|
+
return grid
|
tilearray/types.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Generic type definitions and models for tile-based geospatial data processing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any, Optional, Union
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, model_validator
|
|
10
|
+
from pyproj import Transformer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CRS(str, Enum):
|
|
14
|
+
"""Common Coordinate Reference Systems."""
|
|
15
|
+
|
|
16
|
+
EPSG_4326 = "EPSG:4326"
|
|
17
|
+
EPSG_3857 = "EPSG:3857"
|
|
18
|
+
EPSG_32633 = "EPSG:32633"
|
|
19
|
+
EPSG_27700 = "EPSG:27700"
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_string(cls, crs: str) -> "CRS":
|
|
23
|
+
"""Create CRS from string."""
|
|
24
|
+
if crs.startswith("EPSG:"):
|
|
25
|
+
return cls(crs)
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"Invalid CRS format: {crs}. Expected string, integer, or CRS enum"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_integer(cls, crs: int) -> "CRS":
|
|
32
|
+
"""Create CRS from integer."""
|
|
33
|
+
return cls(f"EPSG:{crs}")
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_epsg(cls, crs: Union[str, int]) -> "CRS":
|
|
37
|
+
"""
|
|
38
|
+
Create CRS from EPSG code.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
crs: EPSG code as string or integer
|
|
42
|
+
- string: "EPSG:4326"
|
|
43
|
+
- integer: 4326
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
CRS enum
|
|
47
|
+
"""
|
|
48
|
+
return cls.from_string(crs) if isinstance(crs, str) else cls.from_integer(crs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Format(str, Enum):
|
|
52
|
+
"""Supported output formats."""
|
|
53
|
+
|
|
54
|
+
GEOTIFF = "image/tiff"
|
|
55
|
+
PNG = "image/png"
|
|
56
|
+
JPEG = "image/jpeg"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
BBoxTuple = tuple[float, float, float, float]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BoundingBox(BaseModel):
|
|
63
|
+
"""Bounding box representation."""
|
|
64
|
+
|
|
65
|
+
min_x: float = Field(..., description="Minimum X coordinate")
|
|
66
|
+
min_y: float = Field(..., description="Minimum Y coordinate")
|
|
67
|
+
max_x: float = Field(..., description="Maximum X coordinate")
|
|
68
|
+
max_y: float = Field(..., description="Maximum Y coordinate")
|
|
69
|
+
crs: CRS = Field(default=CRS.EPSG_4326, description="Coordinate Reference System")
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="after")
|
|
72
|
+
def validate_coordinates(self) -> "BoundingBox":
|
|
73
|
+
"""Validate that min coordinates are less than max coordinates."""
|
|
74
|
+
if self.min_x >= self.max_x:
|
|
75
|
+
raise ValueError("min_x must be less than max_x")
|
|
76
|
+
if self.min_y >= self.max_y:
|
|
77
|
+
raise ValueError("min_y must be less than max_y")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_tuple(
|
|
82
|
+
cls,
|
|
83
|
+
bbox: BBoxTuple,
|
|
84
|
+
crs: Union[CRS, str, int] = CRS.EPSG_4326,
|
|
85
|
+
) -> "BoundingBox":
|
|
86
|
+
"""Create BoundingBox from tuple."""
|
|
87
|
+
|
|
88
|
+
if isinstance(crs, CRS):
|
|
89
|
+
target_crs = crs
|
|
90
|
+
elif isinstance(crs, str):
|
|
91
|
+
crs_upper = crs.upper()
|
|
92
|
+
if crs_upper.startswith("EPSG:"):
|
|
93
|
+
target_crs = CRS.from_string(crs_upper)
|
|
94
|
+
else:
|
|
95
|
+
target_crs = CRS.from_integer(int(crs))
|
|
96
|
+
else:
|
|
97
|
+
target_crs = CRS.from_integer(crs)
|
|
98
|
+
|
|
99
|
+
return cls(
|
|
100
|
+
min_x=bbox[0], min_y=bbox[1], max_x=bbox[2], max_y=bbox[3], crs=target_crs
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def intersects(self, other: "BoundingBox") -> bool:
|
|
104
|
+
"""Check if this bounding box intersects with another."""
|
|
105
|
+
return (
|
|
106
|
+
self.min_x < other.max_x
|
|
107
|
+
and self.max_x > other.min_x
|
|
108
|
+
and self.min_y < other.max_y
|
|
109
|
+
and self.max_y > other.min_y
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def to_crs(self, crs: CRS) -> "BoundingBox":
|
|
113
|
+
"""Transform the bounding box to a new CRS."""
|
|
114
|
+
transformer = Transformer.from_crs(self.crs.value, crs.value, always_xy=True)
|
|
115
|
+
xmin, ymin = transformer.transform(self.min_x, self.min_y)
|
|
116
|
+
xmax, ymax = transformer.transform(self.max_x, self.max_y)
|
|
117
|
+
return BoundingBox(min_x=xmin, min_y=ymin, max_x=xmax, max_y=ymax, crs=crs)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SpatialExtent(BaseModel):
|
|
121
|
+
"""Spatial extent information."""
|
|
122
|
+
|
|
123
|
+
bbox: BoundingBox
|
|
124
|
+
dimensions: Optional[dict[str, Any]] = Field(
|
|
125
|
+
None, description="Optional dimension details"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class TemporalExtent(BaseModel):
|
|
130
|
+
"""Temporal extent information."""
|
|
131
|
+
|
|
132
|
+
start_time: Optional[datetime] = None
|
|
133
|
+
end_time: Optional[datetime] = None
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="after")
|
|
136
|
+
def validate_temporal_order(self) -> "TemporalExtent":
|
|
137
|
+
"""Validate that end_time is after start_time."""
|
|
138
|
+
if self.end_time and self.start_time and self.end_time < self.start_time:
|
|
139
|
+
raise ValueError("end_time must be after start_time")
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CoverageDescription(BaseModel):
|
|
144
|
+
"""Coverage description from WCS GetCapabilities."""
|
|
145
|
+
|
|
146
|
+
identifier: str = Field(..., description="Coverage identifier")
|
|
147
|
+
title: Optional[str] = None
|
|
148
|
+
abstract: Optional[str] = None
|
|
149
|
+
keywords: list[str] = Field(default_factory=list)
|
|
150
|
+
supported_crs: list[CRS] = Field(default_factory=list)
|
|
151
|
+
supported_formats: list[Format] = Field(default_factory=list)
|
|
152
|
+
spatial_extent: Optional[SpatialExtent] = None
|
|
153
|
+
temporal_extent: Optional[TemporalExtent] = None
|
|
154
|
+
native_crs: Optional[CRS] = Field(
|
|
155
|
+
None, description="Native CRS from DescribeCoverage envelope srsName"
|
|
156
|
+
)
|
|
157
|
+
axis_labels: dict[str, tuple[str, str]] = Field(
|
|
158
|
+
default_factory=dict,
|
|
159
|
+
description="Subsetting axis labels keyed by normalized CRS (e.g. EPSG:3857)",
|
|
160
|
+
)
|
|
161
|
+
native_format: Optional[Format] = Field(
|
|
162
|
+
None, description="Preferred output format from DescribeCoverage nativeFormat"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class ServiceCapabilities(BaseModel):
|
|
167
|
+
"""WCS Service Capabilities."""
|
|
168
|
+
|
|
169
|
+
service_title: str
|
|
170
|
+
service_abstract: Optional[str] = None
|
|
171
|
+
service_keywords: list[str] = Field(default_factory=list)
|
|
172
|
+
service_provider: Optional[str] = None
|
|
173
|
+
service_contact: Optional[str] = None
|
|
174
|
+
service_url: str
|
|
175
|
+
version: str = Field(default="2.0.1")
|
|
176
|
+
supported_versions: list[str] = Field(default_factory=lambda: ["2.0.1", "2.0.0"])
|
|
177
|
+
supported_operations: list[str] = Field(
|
|
178
|
+
default_factory=lambda: ["GetCapabilities", "DescribeCoverage", "GetCoverage"]
|
|
179
|
+
)
|
|
180
|
+
supported_formats: list[Format] = Field(default_factory=list)
|
|
181
|
+
supported_crs: list[CRS] = Field(default_factory=list)
|
|
182
|
+
coverages: list[CoverageDescription] = Field(default_factory=list)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class TileRequest(BaseModel):
|
|
186
|
+
"""Generic tile request parameters."""
|
|
187
|
+
|
|
188
|
+
url: str
|
|
189
|
+
params: dict[str, Any]
|
|
190
|
+
headers: Optional[dict[str, str]] = None
|
|
191
|
+
timeout: int = 30
|
|
192
|
+
retries: int = 3
|
|
193
|
+
output_format: Optional[Format] = None
|
|
194
|
+
crs: Optional[CRS] = None
|
|
195
|
+
bbox: Optional[BoundingBox] = None
|
|
196
|
+
width: Optional[int] = None
|
|
197
|
+
height: Optional[int] = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TileResponse(BaseModel):
|
|
201
|
+
"""Response from tile request."""
|
|
202
|
+
|
|
203
|
+
data: bytes
|
|
204
|
+
content_type: str
|
|
205
|
+
status_code: int
|
|
206
|
+
headers: dict[str, str]
|
|
207
|
+
url: str
|
|
208
|
+
success: bool
|
|
209
|
+
error_message: Optional[str] = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class WCSResponse(BaseModel):
|
|
213
|
+
"""Represents a WCS response, either successful data or an error."""
|
|
214
|
+
|
|
215
|
+
success: bool = Field(
|
|
216
|
+
..., description="True if the request was successful, False otherwise"
|
|
217
|
+
)
|
|
218
|
+
data: Optional[Any] = Field(
|
|
219
|
+
None, description="The response data (e.g., image bytes, NetCDF data)"
|
|
220
|
+
)
|
|
221
|
+
error_message: Optional[str] = Field(
|
|
222
|
+
None, description="Error message if the request failed"
|
|
223
|
+
)
|
|
224
|
+
status_code: Optional[int] = Field(
|
|
225
|
+
None, description="HTTP status code of the response"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ServiceTypeEnum(str, Enum):
|
|
230
|
+
"""Service types."""
|
|
231
|
+
|
|
232
|
+
WCS = "WCS"
|
|
233
|
+
WMS = "WMS"
|
|
234
|
+
WMTS = "WMTS"
|
|
235
|
+
XYZ = "XYZ"
|