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,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"