planetary-computer-mcp 1.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.
- planetary_computer_mcp/__init__.py +11 -0
- planetary_computer_mcp/core/__init__.py +41 -0
- planetary_computer_mcp/core/collections.py +112 -0
- planetary_computer_mcp/core/geocoding.py +79 -0
- planetary_computer_mcp/core/raster_utils.py +278 -0
- planetary_computer_mcp/core/stac_client.py +91 -0
- planetary_computer_mcp/core/vector_utils.py +279 -0
- planetary_computer_mcp/core/visualization.py +289 -0
- planetary_computer_mcp/core/zarr_utils.py +387 -0
- planetary_computer_mcp/server.py +142 -0
- planetary_computer_mcp/tools/__init__.py +5 -0
- planetary_computer_mcp/tools/download_data.py +662 -0
- planetary_computer_mcp/tools/download_geometries.py +203 -0
- planetary_computer_mcp-1.1.0.dist-info/METADATA +395 -0
- planetary_computer_mcp-1.1.0.dist-info/RECORD +17 -0
- planetary_computer_mcp-1.1.0.dist-info/WHEEL +4 -0
- planetary_computer_mcp-1.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Planetary Computer MCP Server.
|
|
3
|
+
|
|
4
|
+
A Model Context Protocol (MCP) server for accessing Microsoft's
|
|
5
|
+
Planetary Computer geospatial data catalog.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from planetary_computer_mcp import core, server, tools
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["core", "server", "tools"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core utilities for Planetary Computer MCP server.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from planetary_computer_mcp.core.collections import (
|
|
6
|
+
COLLECTION_KEYWORDS,
|
|
7
|
+
COLLECTION_TYPES,
|
|
8
|
+
detect_collection_from_query,
|
|
9
|
+
get_collection_type,
|
|
10
|
+
)
|
|
11
|
+
from planetary_computer_mcp.core.geocoding import place_to_bbox, validate_bbox
|
|
12
|
+
from planetary_computer_mcp.core.stac_client import PlanetaryComputerSTAC, stac_client
|
|
13
|
+
from planetary_computer_mcp.core.zarr_utils import (
|
|
14
|
+
COORD_MAPPINGS,
|
|
15
|
+
DEFAULT_VARIABLES,
|
|
16
|
+
get_available_variables,
|
|
17
|
+
get_zarr_metadata,
|
|
18
|
+
get_zarr_store_url,
|
|
19
|
+
load_and_compute_zarr,
|
|
20
|
+
load_zarr_data,
|
|
21
|
+
save_zarr_subset_as_netcdf,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"COLLECTION_KEYWORDS",
|
|
26
|
+
"COLLECTION_TYPES",
|
|
27
|
+
"COORD_MAPPINGS",
|
|
28
|
+
"DEFAULT_VARIABLES",
|
|
29
|
+
"PlanetaryComputerSTAC",
|
|
30
|
+
"detect_collection_from_query",
|
|
31
|
+
"get_available_variables",
|
|
32
|
+
"get_collection_type",
|
|
33
|
+
"get_zarr_metadata",
|
|
34
|
+
"get_zarr_store_url",
|
|
35
|
+
"load_and_compute_zarr",
|
|
36
|
+
"load_zarr_data",
|
|
37
|
+
"place_to_bbox",
|
|
38
|
+
"save_zarr_subset_as_netcdf",
|
|
39
|
+
"stac_client",
|
|
40
|
+
"validate_bbox",
|
|
41
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Collection mapping and metadata for dataset auto-detection.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
# Mapping of query keywords to collection IDs
|
|
6
|
+
COLLECTION_KEYWORDS: dict[str, str] = {
|
|
7
|
+
# Optical imagery
|
|
8
|
+
"sentinel-1": "sentinel-1-rtc",
|
|
9
|
+
"sentinel": "sentinel-2-l2a",
|
|
10
|
+
"sentinel-2": "sentinel-2-l2a",
|
|
11
|
+
"satellite": "sentinel-2-l2a", # Default optical
|
|
12
|
+
"naip": "naip",
|
|
13
|
+
"aerial": "naip",
|
|
14
|
+
"landsat": "landsat-c2-l2",
|
|
15
|
+
# DEMs
|
|
16
|
+
"dem": "cop-dem-glo-30",
|
|
17
|
+
"elevation": "cop-dem-glo-30",
|
|
18
|
+
"terrain": "cop-dem-glo-30",
|
|
19
|
+
"copernicus": "cop-dem-glo-30",
|
|
20
|
+
"alos": "alos-dem",
|
|
21
|
+
# Land cover
|
|
22
|
+
"land cover": "esa-worldcover",
|
|
23
|
+
"landcover": "esa-worldcover",
|
|
24
|
+
"lulc": "io-lulc-annual-v02",
|
|
25
|
+
"land use": "io-lulc-annual-v02",
|
|
26
|
+
"worldcover": "esa-worldcover",
|
|
27
|
+
# Vectors
|
|
28
|
+
"building": "ms-buildings",
|
|
29
|
+
"buildings": "ms-buildings",
|
|
30
|
+
"footprint": "ms-buildings",
|
|
31
|
+
# SAR
|
|
32
|
+
"sar": "sentinel-1-rtc",
|
|
33
|
+
"radar": "sentinel-1-rtc",
|
|
34
|
+
# Climate / Weather (Zarr-based)
|
|
35
|
+
"gridmet": "gridmet",
|
|
36
|
+
"terraclimate": "terraclimate",
|
|
37
|
+
"daymet": "daymet-daily-na",
|
|
38
|
+
"climate": "gridmet", # Default to GridMET (CONUS, 4km, 1979-2020)
|
|
39
|
+
"weather": "gridmet",
|
|
40
|
+
"temperature": "gridmet",
|
|
41
|
+
"precipitation": "gridmet",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect_collection_from_query(query: str) -> str | None:
|
|
46
|
+
"""
|
|
47
|
+
Detect collection ID from natural language query.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
query : str
|
|
52
|
+
User query string
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
str or None
|
|
57
|
+
Collection ID or None if not detected
|
|
58
|
+
"""
|
|
59
|
+
query_lower = query.lower()
|
|
60
|
+
|
|
61
|
+
# Direct collection name matches
|
|
62
|
+
for keyword, collection in COLLECTION_KEYWORDS.items():
|
|
63
|
+
if keyword in query_lower:
|
|
64
|
+
return collection
|
|
65
|
+
|
|
66
|
+
# Fallback to Sentinel-2 for general imagery queries
|
|
67
|
+
if any(word in query_lower for word in ["imagery", "image", "satellite", "remote sensing"]):
|
|
68
|
+
return "sentinel-2-l2a"
|
|
69
|
+
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# Collection metadata for tool routing
|
|
74
|
+
COLLECTION_TYPES: dict[str, str] = {
|
|
75
|
+
# Raster (COG-based via STAC)
|
|
76
|
+
"sentinel-2-l2a": "raster",
|
|
77
|
+
"naip": "raster",
|
|
78
|
+
"landsat-c2-l2": "raster",
|
|
79
|
+
"cop-dem-glo-30": "raster",
|
|
80
|
+
"alos-dem": "raster",
|
|
81
|
+
"esa-worldcover": "raster",
|
|
82
|
+
"io-lulc-annual-v02": "raster",
|
|
83
|
+
"sentinel-1-rtc": "raster",
|
|
84
|
+
# Zarr-based climate/weather data
|
|
85
|
+
"gridmet": "zarr",
|
|
86
|
+
"terraclimate": "zarr",
|
|
87
|
+
"daymet-daily-na": "zarr",
|
|
88
|
+
"daymet-daily-hi": "zarr",
|
|
89
|
+
"daymet-daily-pr": "zarr",
|
|
90
|
+
"daymet-monthly-na": "zarr",
|
|
91
|
+
"daymet-annual-na": "zarr",
|
|
92
|
+
"era5-pds": "zarr",
|
|
93
|
+
# Vector (GeoParquet)
|
|
94
|
+
"ms-buildings": "vector",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_collection_type(collection_id: str) -> str:
|
|
99
|
+
"""
|
|
100
|
+
Get the data type for a collection.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
collection_id : str
|
|
105
|
+
Collection ID
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
str
|
|
110
|
+
"raster", "vector", or "zarr"
|
|
111
|
+
"""
|
|
112
|
+
return COLLECTION_TYPES.get(collection_id, "raster") # Default to raster
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geocoding utilities for converting place names to bounding boxes.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from geopy.extra.rate_limiter import RateLimiter
|
|
6
|
+
from geopy.geocoders import Nominatim
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def place_to_bbox(place_name: str) -> list[float]:
|
|
10
|
+
"""
|
|
11
|
+
Convert place name to [west, south, east, north] bbox.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
"San Antonio" → [-98.79, 29.22, -98.28, 29.76]
|
|
15
|
+
"New York City" → [-74.26, 40.50, -73.70, 40.92]
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
place_name : str
|
|
20
|
+
Human-readable place name
|
|
21
|
+
|
|
22
|
+
Returns
|
|
23
|
+
-------
|
|
24
|
+
list[float]
|
|
25
|
+
Bounding box as [west, south, east, north]
|
|
26
|
+
|
|
27
|
+
Raises
|
|
28
|
+
------
|
|
29
|
+
ValueError
|
|
30
|
+
If geocoding fails
|
|
31
|
+
"""
|
|
32
|
+
geolocator = Nominatim(user_agent="planetary-computer-mcp")
|
|
33
|
+
geocode = RateLimiter(geolocator.geocode, min_delay_seconds=1)
|
|
34
|
+
location = geocode(place_name, exactly_one=True)
|
|
35
|
+
|
|
36
|
+
if location and hasattr(location, "raw") and location.raw.get("boundingbox"):
|
|
37
|
+
bb = location.raw["boundingbox"]
|
|
38
|
+
# boundingbox is [south, north, west, east] as strings
|
|
39
|
+
return [float(bb[2]), float(bb[0]), float(bb[3]), float(bb[1])]
|
|
40
|
+
|
|
41
|
+
raise ValueError(f"Could not geocode: {place_name}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_bbox(bbox: list[float]) -> list[float]:
|
|
45
|
+
"""
|
|
46
|
+
Validate and normalize a bounding box.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
bbox : list[float]
|
|
51
|
+
[west, south, east, north]
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
list[float]
|
|
56
|
+
Normalized bbox
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
ValueError
|
|
61
|
+
If bbox is invalid
|
|
62
|
+
"""
|
|
63
|
+
if len(bbox) != 4:
|
|
64
|
+
raise ValueError("Bbox must have 4 elements [west, south, east, north]")
|
|
65
|
+
|
|
66
|
+
west, south, east, north = bbox
|
|
67
|
+
|
|
68
|
+
if west >= east:
|
|
69
|
+
raise ValueError("West must be less than east")
|
|
70
|
+
if south >= north:
|
|
71
|
+
raise ValueError("South must be less than north")
|
|
72
|
+
|
|
73
|
+
# Basic bounds checking
|
|
74
|
+
if not (-180 <= west <= 180 and -180 <= east <= 180):
|
|
75
|
+
raise ValueError("Longitude must be between -180 and 180")
|
|
76
|
+
if not (-90 <= south <= 90 and -90 <= north <= 90):
|
|
77
|
+
raise ValueError("Latitude must be between -90 and 90")
|
|
78
|
+
|
|
79
|
+
return [west, south, east, north]
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Raster utilities using odc-stac for COG loading and processing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import rioxarray # noqa: F401
|
|
9
|
+
import xarray as xr
|
|
10
|
+
from odc.stac import load
|
|
11
|
+
from pystac import Item
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_raster_from_stac(
|
|
15
|
+
items: list[Item],
|
|
16
|
+
bbox: list[float] | None = None,
|
|
17
|
+
bands: list[str] | None = None,
|
|
18
|
+
resolution: float | None = None,
|
|
19
|
+
crs: str = "EPSG:4326",
|
|
20
|
+
) -> xr.Dataset:
|
|
21
|
+
"""
|
|
22
|
+
Load raster data from STAC items using odc-stac.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
items : list[Item]
|
|
27
|
+
Signed STAC items
|
|
28
|
+
bbox : list[float] or None, optional
|
|
29
|
+
Bounding box [west, south, east, north]
|
|
30
|
+
bands : list[str] or None, optional
|
|
31
|
+
Specific bands to load (None for all)
|
|
32
|
+
resolution : float or None, optional
|
|
33
|
+
Output resolution in CRS units
|
|
34
|
+
crs : str, optional
|
|
35
|
+
Output CRS (default EPSG:4326)
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
xr.Dataset
|
|
40
|
+
Xarray Dataset with raster data
|
|
41
|
+
"""
|
|
42
|
+
load_kwargs: dict[str, Any] = {
|
|
43
|
+
"crs": crs,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if bands:
|
|
47
|
+
load_kwargs["bands"] = bands
|
|
48
|
+
|
|
49
|
+
# Resolution must be provided - caller should pass native resolution
|
|
50
|
+
if resolution:
|
|
51
|
+
load_kwargs["resolution"] = resolution
|
|
52
|
+
else:
|
|
53
|
+
raise ValueError("Resolution is required for odc-stac loading")
|
|
54
|
+
|
|
55
|
+
# Bbox must be passed to odc-stac load() for proper clipping
|
|
56
|
+
if bbox:
|
|
57
|
+
load_kwargs["bbox"] = bbox
|
|
58
|
+
|
|
59
|
+
# Load with odc-stac
|
|
60
|
+
return load(items, **load_kwargs)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def load_multiband_asset(
|
|
64
|
+
items: list[Item],
|
|
65
|
+
asset_name: str,
|
|
66
|
+
bbox: list[float] | None = None,
|
|
67
|
+
resolution: float | None = None,
|
|
68
|
+
band_names: list[str] | None = None,
|
|
69
|
+
) -> xr.Dataset:
|
|
70
|
+
"""
|
|
71
|
+
Load a multi-band asset (like NAIP 'image') using rioxarray.
|
|
72
|
+
|
|
73
|
+
odc-stac doesn't handle multi-band single-asset collections well,
|
|
74
|
+
so we use rioxarray directly for these cases.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
items : list[Item]
|
|
79
|
+
Signed STAC items (uses first item only)
|
|
80
|
+
asset_name : str
|
|
81
|
+
Name of the asset to load (e.g., 'image')
|
|
82
|
+
bbox : list[float] or None, optional
|
|
83
|
+
Bounding box [west, south, east, north] in EPSG:4326
|
|
84
|
+
resolution : float or None, optional
|
|
85
|
+
Output resolution in degrees (ignored - uses native resolution)
|
|
86
|
+
band_names : list[str] or None, optional
|
|
87
|
+
Names for the bands (e.g., ['red', 'green', 'blue', 'nir'])
|
|
88
|
+
|
|
89
|
+
Returns
|
|
90
|
+
-------
|
|
91
|
+
xr.Dataset
|
|
92
|
+
Xarray Dataset with named bands
|
|
93
|
+
"""
|
|
94
|
+
import rioxarray as rxr # Local import for type checking
|
|
95
|
+
from rasterio.crs import CRS # type: ignore[import-not-found]
|
|
96
|
+
from rasterio.warp import transform_bounds # type: ignore[import-not-found]
|
|
97
|
+
|
|
98
|
+
if not items:
|
|
99
|
+
raise ValueError("No items provided")
|
|
100
|
+
|
|
101
|
+
item = items[0] # Use first item
|
|
102
|
+
if asset_name not in item.assets:
|
|
103
|
+
raise ValueError(f"Asset '{asset_name}' not found in item")
|
|
104
|
+
|
|
105
|
+
href = item.assets[asset_name].href
|
|
106
|
+
|
|
107
|
+
# Load with rioxarray (supports COGs with windowed reads)
|
|
108
|
+
data = rxr.open_rasterio(href)
|
|
109
|
+
if not isinstance(data, xr.DataArray):
|
|
110
|
+
raise TypeError("Expected DataArray from rioxarray")
|
|
111
|
+
|
|
112
|
+
# Clip and reproject if bbox provided
|
|
113
|
+
if bbox:
|
|
114
|
+
native_crs = data.rio.crs
|
|
115
|
+
|
|
116
|
+
# Transform bbox from WGS84 to native CRS for clipping
|
|
117
|
+
if native_crs and str(native_crs) != "EPSG:4326":
|
|
118
|
+
west, south, east, north = bbox
|
|
119
|
+
native_bounds = transform_bounds(
|
|
120
|
+
CRS.from_epsg(4326),
|
|
121
|
+
native_crs,
|
|
122
|
+
west,
|
|
123
|
+
south,
|
|
124
|
+
east,
|
|
125
|
+
north,
|
|
126
|
+
)
|
|
127
|
+
# Clip in native CRS (preserves native resolution)
|
|
128
|
+
with contextlib.suppress(Exception):
|
|
129
|
+
data = data.rio.clip_box(*native_bounds)
|
|
130
|
+
|
|
131
|
+
# Reproject to WGS84 after clipping
|
|
132
|
+
data = data.rio.reproject("EPSG:4326")
|
|
133
|
+
else:
|
|
134
|
+
# Already in WGS84, just clip
|
|
135
|
+
with contextlib.suppress(Exception):
|
|
136
|
+
data = data.rio.clip_box(*bbox)
|
|
137
|
+
|
|
138
|
+
# Convert to Dataset with named bands
|
|
139
|
+
num_bands = data.shape[0] if len(data.shape) > 2 else 1
|
|
140
|
+
if band_names and len(band_names) <= num_bands:
|
|
141
|
+
# Create dataset with named bands
|
|
142
|
+
datasets = {}
|
|
143
|
+
for i, name in enumerate(band_names):
|
|
144
|
+
if i < num_bands:
|
|
145
|
+
band_data = data.isel(band=i) if len(data.shape) > 2 else data
|
|
146
|
+
datasets[name] = band_data.drop_vars("band", errors="ignore")
|
|
147
|
+
return xr.Dataset(datasets)
|
|
148
|
+
else:
|
|
149
|
+
# Return as single variable
|
|
150
|
+
return xr.Dataset({"data": data})
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_raster_as_geotiff(
|
|
154
|
+
data: xr.Dataset,
|
|
155
|
+
output_path: str,
|
|
156
|
+
nodata: float | None = None,
|
|
157
|
+
) -> str:
|
|
158
|
+
"""
|
|
159
|
+
Save raster data as GeoTIFF.
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
data : xr.Dataset
|
|
164
|
+
Xarray Dataset
|
|
165
|
+
output_path : str
|
|
166
|
+
Output file path
|
|
167
|
+
nodata : float or None, optional
|
|
168
|
+
NoData value
|
|
169
|
+
|
|
170
|
+
Returns
|
|
171
|
+
-------
|
|
172
|
+
str
|
|
173
|
+
Path to saved file
|
|
174
|
+
"""
|
|
175
|
+
# Handle temporal data - take the most recent if multiple time slices
|
|
176
|
+
if "time" in data.sizes and data.sizes["time"] > 1:
|
|
177
|
+
# Take the last (most recent) time slice
|
|
178
|
+
data = data.isel(time=-1)
|
|
179
|
+
elif "time" in data.sizes and data.sizes["time"] == 1:
|
|
180
|
+
# Remove singleton time dimension
|
|
181
|
+
data = data.isel(time=0)
|
|
182
|
+
|
|
183
|
+
# For multi-band data, save as multi-band GeoTIFF
|
|
184
|
+
# For single band, extract the DataArray
|
|
185
|
+
if isinstance(data, xr.Dataset) and len(data.data_vars) == 1:
|
|
186
|
+
data_array = data[next(iter(data.data_vars.keys()))]
|
|
187
|
+
elif isinstance(data, xr.Dataset):
|
|
188
|
+
# Multi-band - convert to DataArray with band dimension
|
|
189
|
+
data_array = data.to_array(dim="band")
|
|
190
|
+
else:
|
|
191
|
+
data_array = data
|
|
192
|
+
|
|
193
|
+
# Ensure the data has proper CRS and transform
|
|
194
|
+
if not hasattr(data_array, "rio") or data_array.rio.crs is None:
|
|
195
|
+
# Assume WGS84 if no CRS
|
|
196
|
+
data_array = data_array.rio.write_crs("EPSG:4326")
|
|
197
|
+
|
|
198
|
+
# Set nodata if provided
|
|
199
|
+
if nodata is not None:
|
|
200
|
+
data_array = data_array.rio.write_nodata(nodata)
|
|
201
|
+
|
|
202
|
+
# Save as GeoTIFF
|
|
203
|
+
data_array.rio.to_raster(output_path)
|
|
204
|
+
|
|
205
|
+
return output_path
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def get_raster_metadata(data: xr.Dataset) -> dict[str, Any]:
|
|
209
|
+
"""
|
|
210
|
+
Extract metadata from raster Dataset.
|
|
211
|
+
|
|
212
|
+
Parameters
|
|
213
|
+
----------
|
|
214
|
+
data : xr.Dataset
|
|
215
|
+
Xarray Dataset
|
|
216
|
+
|
|
217
|
+
Returns
|
|
218
|
+
-------
|
|
219
|
+
dict[str, Any]
|
|
220
|
+
Dictionary with metadata
|
|
221
|
+
"""
|
|
222
|
+
# Use the first variable for metadata
|
|
223
|
+
if isinstance(data, xr.Dataset) and len(data.data_vars) > 0:
|
|
224
|
+
sample_var = next(iter(data.data_vars.keys()))
|
|
225
|
+
data_array = data[sample_var]
|
|
226
|
+
else:
|
|
227
|
+
data_array = data
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
"crs": str(data_array.rio.crs)
|
|
231
|
+
if hasattr(data_array, "rio") and data_array.rio.crs
|
|
232
|
+
else None,
|
|
233
|
+
"bounds": data_array.rio.bounds() if hasattr(data_array, "rio") else None,
|
|
234
|
+
"resolution": data_array.rio.resolution() if hasattr(data_array, "rio") else None,
|
|
235
|
+
"shape": data_array.shape,
|
|
236
|
+
"dtype": str(data_array.dtype),
|
|
237
|
+
"bands": list(data.data_vars)
|
|
238
|
+
if isinstance(data, xr.Dataset)
|
|
239
|
+
else [data_array.name]
|
|
240
|
+
if data_array.name
|
|
241
|
+
else ["data"],
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def crop_raster_to_bbox(
|
|
246
|
+
data: xr.Dataset,
|
|
247
|
+
bbox: list[float],
|
|
248
|
+
) -> xr.Dataset:
|
|
249
|
+
"""
|
|
250
|
+
Crop raster to bounding box.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
data : xr.Dataset
|
|
255
|
+
Xarray Dataset
|
|
256
|
+
bbox : list[float]
|
|
257
|
+
[west, south, east, north]
|
|
258
|
+
|
|
259
|
+
Returns
|
|
260
|
+
-------
|
|
261
|
+
xr.Dataset
|
|
262
|
+
Cropped Dataset
|
|
263
|
+
"""
|
|
264
|
+
west, south, east, north = bbox
|
|
265
|
+
|
|
266
|
+
# Apply to all variables
|
|
267
|
+
cropped = {}
|
|
268
|
+
for var_name in data.data_vars:
|
|
269
|
+
try:
|
|
270
|
+
cropped[var_name] = data[var_name].rio.clip_box(
|
|
271
|
+
west, south, east, north, allow_one_dimensional_raster=True
|
|
272
|
+
)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
# If clipping fails, return original
|
|
275
|
+
print(f"Warning: Clipping failed for {var_name}: {e}")
|
|
276
|
+
cropped[var_name] = data[var_name]
|
|
277
|
+
|
|
278
|
+
return xr.Dataset(cropped, attrs=data.attrs)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
STAC client wrapper for Planetary Computer.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import planetary_computer as pc
|
|
6
|
+
from pystac import Item
|
|
7
|
+
from pystac_client import Client
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PlanetaryComputerSTAC:
|
|
11
|
+
"""Wrapper for Planetary Computer STAC operations."""
|
|
12
|
+
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.catalog_url = "https://planetarycomputer.microsoft.com/api/stac/v1"
|
|
15
|
+
self.client = Client.open(self.catalog_url)
|
|
16
|
+
|
|
17
|
+
def search_items(
|
|
18
|
+
self,
|
|
19
|
+
collections: list[str],
|
|
20
|
+
bbox: list[float] | None = None,
|
|
21
|
+
datetime: str | None = None,
|
|
22
|
+
max_cloud_cover: int | None = None,
|
|
23
|
+
limit: int | None = None,
|
|
24
|
+
) -> list[Item]:
|
|
25
|
+
"""
|
|
26
|
+
Search for STAC items.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
collections : list[str]
|
|
31
|
+
List of collection IDs
|
|
32
|
+
bbox : list[float] or None, optional
|
|
33
|
+
Bounding box [west, south, east, north]
|
|
34
|
+
datetime : str or None, optional
|
|
35
|
+
ISO8601 datetime range
|
|
36
|
+
max_cloud_cover : int or None, optional
|
|
37
|
+
Maximum cloud cover percentage
|
|
38
|
+
limit : int or None, optional
|
|
39
|
+
Maximum number of items to return
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
list[Item]
|
|
44
|
+
List of signed STAC items
|
|
45
|
+
"""
|
|
46
|
+
query_params = {}
|
|
47
|
+
if max_cloud_cover is not None:
|
|
48
|
+
query_params["eo:cloud_cover"] = {"lt": max_cloud_cover}
|
|
49
|
+
|
|
50
|
+
search = self.client.search(
|
|
51
|
+
collections=collections,
|
|
52
|
+
bbox=bbox,
|
|
53
|
+
datetime=datetime,
|
|
54
|
+
query=query_params if query_params else None,
|
|
55
|
+
limit=limit,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
items = list(search.items())
|
|
59
|
+
return [pc.sign(item) for item in items]
|
|
60
|
+
|
|
61
|
+
def get_collection_info(self, collection_id: str) -> dict:
|
|
62
|
+
"""
|
|
63
|
+
Get basic info about a collection.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
collection_id : str
|
|
68
|
+
Collection ID
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
dict
|
|
73
|
+
Dictionary with collection metadata
|
|
74
|
+
"""
|
|
75
|
+
collection = self.client.get_collection(collection_id)
|
|
76
|
+
return {
|
|
77
|
+
"id": collection.id,
|
|
78
|
+
"title": collection.title or "",
|
|
79
|
+
"description": collection.description or "",
|
|
80
|
+
"providers": [p.name for p in collection.providers] if collection.providers else [],
|
|
81
|
+
"extent": {
|
|
82
|
+
"temporal": collection.extent.temporal.intervals
|
|
83
|
+
if collection.extent.temporal
|
|
84
|
+
else None,
|
|
85
|
+
"spatial": collection.extent.spatial.bboxes if collection.extent.spatial else None,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Global instance
|
|
91
|
+
stac_client = PlanetaryComputerSTAC()
|