stac-eo-mcp 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.
- stac_eo_mcp/__init__.py +3 -0
- stac_eo_mcp/__main__.py +4 -0
- stac_eo_mcp/analysis.py +241 -0
- stac_eo_mcp/catalog.py +353 -0
- stac_eo_mcp/config.py +41 -0
- stac_eo_mcp/errors.py +39 -0
- stac_eo_mcp/indices.py +186 -0
- stac_eo_mcp/models.py +312 -0
- stac_eo_mcp/raster.py +105 -0
- stac_eo_mcp/server.py +338 -0
- stac_eo_mcp-0.1.0.dist-info/METADATA +223 -0
- stac_eo_mcp-0.1.0.dist-info/RECORD +15 -0
- stac_eo_mcp-0.1.0.dist-info/WHEEL +4 -0
- stac_eo_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- stac_eo_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
stac_eo_mcp/__init__.py
ADDED
stac_eo_mcp/__main__.py
ADDED
stac_eo_mcp/analysis.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Tool workflows: scene -> window read -> reflectance -> index -> stats/files."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pystac
|
|
9
|
+
import xarray as xr
|
|
10
|
+
from shapely.geometry import box, shape
|
|
11
|
+
|
|
12
|
+
from stac_eo_mcp import catalog, raster
|
|
13
|
+
from stac_eo_mcp.config import CatalogName, Settings
|
|
14
|
+
from stac_eo_mcp.errors import InvalidInputError, StacMcpError
|
|
15
|
+
from stac_eo_mcp.indices import INDEX_DEFINITIONS, compute_index, compute_stats, to_reflectance
|
|
16
|
+
from stac_eo_mcp.models import (
|
|
17
|
+
BBox,
|
|
18
|
+
DateRange,
|
|
19
|
+
IndexResult,
|
|
20
|
+
IndexStats,
|
|
21
|
+
SaveFormat,
|
|
22
|
+
SkippedScene,
|
|
23
|
+
SpectralIndex,
|
|
24
|
+
TimeSeriesPoint,
|
|
25
|
+
TimeSeriesResult,
|
|
26
|
+
TimeSeriesSummary,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
log = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
RESOLUTION_M = 10.0
|
|
32
|
+
LOW_VALID_PCT = 20.0
|
|
33
|
+
# Metadata-only search pool for time series; pixels are read for max_scenes at most.
|
|
34
|
+
SEARCH_POOL = 500
|
|
35
|
+
READ_WORKERS = 4
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --- Single scene ---------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def check_overlap(item: pystac.Item, bbox: BBox) -> None:
|
|
42
|
+
if item.geometry and not shape(item.geometry).intersects(box(*bbox.as_list())):
|
|
43
|
+
raise InvalidInputError(
|
|
44
|
+
f"The bbox does not overlap scene {item.id}.",
|
|
45
|
+
hint="Use search_scenes with this bbox to find scenes that cover it.",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def index_from_dataset(
|
|
50
|
+
ds: xr.Dataset, item: pystac.Item, asset_keys: dict[str, str], index: SpectralIndex
|
|
51
|
+
) -> xr.DataArray:
|
|
52
|
+
"""Turn a raw-DN window into a masked index DataArray on the same grid."""
|
|
53
|
+
definition = INDEX_DEFINITIONS[index]
|
|
54
|
+
bands = {}
|
|
55
|
+
for name in definition.bands:
|
|
56
|
+
scaling = catalog.band_scaling(item, asset_keys[name])
|
|
57
|
+
bands[name] = to_reflectance(ds[name].values, scaling.offset_dn, scaling.scale)
|
|
58
|
+
scl = ds["scl"].values if "scl" in ds else None
|
|
59
|
+
values = compute_index(index, bands, scl=scl)
|
|
60
|
+
template = ds[definition.a]
|
|
61
|
+
return xr.DataArray(values, coords=template.coords, dims=template.dims, name=index.value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def index_on_item(
|
|
65
|
+
catalog_name: CatalogName, item: pystac.Item, index: SpectralIndex, bbox: BBox
|
|
66
|
+
) -> tuple[xr.DataArray, IndexStats]:
|
|
67
|
+
"""Read the bbox window of one scene and compute the masked index."""
|
|
68
|
+
check_overlap(item, bbox)
|
|
69
|
+
keys = catalog.band_asset_keys(catalog_name, item, [*INDEX_DEFINITIONS[index].bands, "scl"])
|
|
70
|
+
sign = catalog.get_config(catalog_name).sign
|
|
71
|
+
ds = raster.load_window(item, keys, bbox, resolution=RESOLUTION_M, sign=sign)
|
|
72
|
+
index_da = index_from_dataset(ds, item, keys, index)
|
|
73
|
+
return index_da, compute_stats(index_da.values)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def compute_index_for_item(
|
|
77
|
+
settings: Settings,
|
|
78
|
+
catalog_name: CatalogName,
|
|
79
|
+
item_id: str,
|
|
80
|
+
index: SpectralIndex,
|
|
81
|
+
bbox: BBox,
|
|
82
|
+
save: SaveFormat = SaveFormat.NONE,
|
|
83
|
+
) -> IndexResult:
|
|
84
|
+
bbox.check_area(settings.max_area_km2)
|
|
85
|
+
cfg = catalog.get_config(catalog_name)
|
|
86
|
+
item = catalog.get_item(catalog_name, cfg.s2_collection, item_id)
|
|
87
|
+
index_da, stats = index_on_item(catalog_name, item, index, bbox)
|
|
88
|
+
|
|
89
|
+
outputs: dict[str, str] = {}
|
|
90
|
+
if save is not SaveFormat.NONE and stats.valid_pixels:
|
|
91
|
+
stem = settings.output_dir / raster.output_stem(item.id, index, bbox)
|
|
92
|
+
if save in (SaveFormat.PNG, SaveFormat.BOTH):
|
|
93
|
+
outputs["png"] = str(raster.save_png(index_da.values, index, stem.with_suffix(".png")))
|
|
94
|
+
if save in (SaveFormat.GEOTIFF, SaveFormat.BOTH):
|
|
95
|
+
outputs["geotiff"] = str(raster.save_geotiff(index_da, stem.with_suffix(".tif")))
|
|
96
|
+
|
|
97
|
+
return IndexResult(
|
|
98
|
+
item_id=item.id,
|
|
99
|
+
index=index,
|
|
100
|
+
datetime=catalog.summarize_item(item, catalog_name).datetime,
|
|
101
|
+
bbox=bbox.as_list(),
|
|
102
|
+
stats=stats,
|
|
103
|
+
resolution_m=RESOLUTION_M,
|
|
104
|
+
outputs=outputs,
|
|
105
|
+
note=_quality_note(stats.valid_pixel_pct),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _quality_note(valid_pct: float) -> str | None:
|
|
110
|
+
if valid_pct == 0:
|
|
111
|
+
return (
|
|
112
|
+
"All pixels are masked (cloud, shadow or no data) over this bbox, so no statistics "
|
|
113
|
+
"and no files. Try another scene from search_scenes with a lower max_cloud_cover."
|
|
114
|
+
)
|
|
115
|
+
if valid_pct < LOW_VALID_PCT:
|
|
116
|
+
return (
|
|
117
|
+
f"Only {valid_pct:.1f}% of pixels are clear; the statistics may not be "
|
|
118
|
+
"representative. Consider another scene."
|
|
119
|
+
)
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# --- Time series ----------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _day(item: pystac.Item) -> str:
|
|
127
|
+
return item.datetime.date().isoformat() if item.datetime else "unknown"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def one_per_day(items: list[pystac.Item], bbox: BBox) -> list[pystac.Item]:
|
|
131
|
+
"""Keep one scene per acquisition day, oldest first.
|
|
132
|
+
|
|
133
|
+
Areas on a tile overlap are covered by two items per pass. Pick the one
|
|
134
|
+
whose footprint covers most of the bbox, then the lower cloud cover.
|
|
135
|
+
"""
|
|
136
|
+
target = box(*bbox.as_list())
|
|
137
|
+
by_day: dict[str, list[pystac.Item]] = defaultdict(list)
|
|
138
|
+
for item in items:
|
|
139
|
+
by_day[_day(item)].append(item)
|
|
140
|
+
|
|
141
|
+
def rank(item: pystac.Item) -> tuple[float, float]:
|
|
142
|
+
covered = shape(item.geometry).intersection(target).area if item.geometry else 0.0
|
|
143
|
+
cloud = item.properties.get("eo:cloud_cover", 100.0)
|
|
144
|
+
return (-round(covered / target.area, 3), cloud)
|
|
145
|
+
|
|
146
|
+
return [min(group, key=rank) for _, group in sorted(by_day.items())]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def sample_evenly(items: list[pystac.Item], n: int) -> list[pystac.Item]:
|
|
150
|
+
"""Pick n items spread across the list, always keeping first and last."""
|
|
151
|
+
if len(items) <= n:
|
|
152
|
+
return items
|
|
153
|
+
idx = np.unique(np.linspace(0, len(items) - 1, n).round().astype(int))
|
|
154
|
+
return [items[i] for i in idx]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def time_series(
|
|
158
|
+
settings: Settings,
|
|
159
|
+
catalog_name: CatalogName,
|
|
160
|
+
index: SpectralIndex,
|
|
161
|
+
bbox: BBox,
|
|
162
|
+
date_range: DateRange,
|
|
163
|
+
max_cloud_cover: float | None = None,
|
|
164
|
+
) -> TimeSeriesResult:
|
|
165
|
+
bbox.check_area(settings.max_area_km2)
|
|
166
|
+
cfg = catalog.get_config(catalog_name)
|
|
167
|
+
found = catalog.search_items(
|
|
168
|
+
catalog_name, cfg.s2_collection, bbox, date_range, max_cloud_cover, limit=SEARCH_POOL
|
|
169
|
+
)
|
|
170
|
+
daily = one_per_day(found, bbox)
|
|
171
|
+
selected = sample_evenly(daily, settings.max_scenes)
|
|
172
|
+
log.info("time_series: %d scenes, %d dates, reading %d", len(found), len(daily), len(selected))
|
|
173
|
+
|
|
174
|
+
def analyse(item: pystac.Item) -> TimeSeriesPoint | SkippedScene:
|
|
175
|
+
try:
|
|
176
|
+
_, stats = index_on_item(catalog_name, item, index, bbox)
|
|
177
|
+
except StacMcpError as exc:
|
|
178
|
+
return SkippedScene(date=_day(item), item_id=item.id, reason=exc.message)
|
|
179
|
+
except Exception as exc: # one bad scene must not sink the series
|
|
180
|
+
log.warning("time_series: %s failed: %s", item.id, exc)
|
|
181
|
+
return SkippedScene(date=_day(item), item_id=item.id, reason=f"read error: {exc}")
|
|
182
|
+
if stats.mean is None or stats.valid_pixel_pct < LOW_VALID_PCT:
|
|
183
|
+
return SkippedScene(
|
|
184
|
+
date=_day(item),
|
|
185
|
+
item_id=item.id,
|
|
186
|
+
reason=f"only {stats.valid_pixel_pct:.1f}% clear pixels over the bbox",
|
|
187
|
+
)
|
|
188
|
+
return TimeSeriesPoint(
|
|
189
|
+
date=_day(item),
|
|
190
|
+
item_id=item.id,
|
|
191
|
+
mean=stats.mean,
|
|
192
|
+
std=stats.std,
|
|
193
|
+
valid_pixel_pct=stats.valid_pixel_pct,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
with ThreadPoolExecutor(max_workers=READ_WORKERS) as pool:
|
|
197
|
+
results = list(pool.map(analyse, selected))
|
|
198
|
+
|
|
199
|
+
points = [r for r in results if isinstance(r, TimeSeriesPoint)]
|
|
200
|
+
skipped = [r for r in results if isinstance(r, SkippedScene)]
|
|
201
|
+
return TimeSeriesResult(
|
|
202
|
+
index=index,
|
|
203
|
+
bbox=bbox.as_list(),
|
|
204
|
+
date_range=f"{date_range.start}/{date_range.end}",
|
|
205
|
+
scenes_found=len(found),
|
|
206
|
+
dates_analysed=len(selected),
|
|
207
|
+
points=points,
|
|
208
|
+
skipped=skipped,
|
|
209
|
+
summary=_summarize(points),
|
|
210
|
+
note=_series_note(len(daily), len(selected), points, settings.max_scenes),
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _summarize(points: list[TimeSeriesPoint]) -> TimeSeriesSummary | None:
|
|
215
|
+
if not points:
|
|
216
|
+
return None
|
|
217
|
+
return TimeSeriesSummary(
|
|
218
|
+
mean_of_means=round(float(np.mean([p.mean for p in points])), 4),
|
|
219
|
+
min=min(points, key=lambda p: p.mean),
|
|
220
|
+
max=max(points, key=lambda p: p.mean),
|
|
221
|
+
change=round(points[-1].mean - points[0].mean, 4),
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _series_note(
|
|
226
|
+
n_dates: int, n_selected: int, points: list[TimeSeriesPoint], max_scenes: int
|
|
227
|
+
) -> str | None:
|
|
228
|
+
notes = []
|
|
229
|
+
if n_selected < n_dates:
|
|
230
|
+
notes.append(
|
|
231
|
+
f"{n_dates} acquisition dates matched; {n_selected} were sampled evenly "
|
|
232
|
+
f"(limit {max_scenes}). Narrow the date range for every date."
|
|
233
|
+
)
|
|
234
|
+
if not points:
|
|
235
|
+
notes.append(
|
|
236
|
+
"No date had enough clear pixels. Raise max_cloud_cover, widen the date range "
|
|
237
|
+
"or move the bbox."
|
|
238
|
+
)
|
|
239
|
+
elif len(points) < 3:
|
|
240
|
+
notes.append("Fewer than 3 usable dates: treat any trend with caution.")
|
|
241
|
+
return " ".join(notes) or None
|
stac_eo_mcp/catalog.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""STAC catalog access: clients, search, item lookup and Sentinel-2 band mapping.
|
|
2
|
+
|
|
3
|
+
Supported catalogs:
|
|
4
|
+
- Microsoft Planetary Computer (assets are signed with planetary-computer)
|
|
5
|
+
- Element84 Earth Search (public, no signing)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import difflib
|
|
9
|
+
import logging
|
|
10
|
+
import warnings
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import planetary_computer
|
|
16
|
+
import pystac
|
|
17
|
+
from pystac_client import Client
|
|
18
|
+
from pystac_client.exceptions import APIError
|
|
19
|
+
from shapely.geometry import mapping, shape
|
|
20
|
+
|
|
21
|
+
from stac_eo_mcp.config import CatalogName
|
|
22
|
+
from stac_eo_mcp.errors import CatalogError, DataUnavailableError, InvalidInputError, NoScenesError
|
|
23
|
+
from stac_eo_mcp.indices import S2_BASELINE_OFFSET_DN, S2_SCALE
|
|
24
|
+
from stac_eo_mcp.models import (
|
|
25
|
+
AssetInfo,
|
|
26
|
+
BBox,
|
|
27
|
+
CollectionSummary,
|
|
28
|
+
DateRange,
|
|
29
|
+
SceneDetails,
|
|
30
|
+
SceneSummary,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# Earth Search items trigger a harmless pystac storage-extension migration warning.
|
|
36
|
+
warnings.filterwarnings("ignore", message="Could not parse bucket/account from href")
|
|
37
|
+
|
|
38
|
+
# Canonical band name -> asset key, per catalog.
|
|
39
|
+
CANONICAL_BANDS = ("blue", "green", "red", "nir", "swir16", "swir22", "scl")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class CatalogConfig:
|
|
44
|
+
name: CatalogName
|
|
45
|
+
url: str
|
|
46
|
+
title: str
|
|
47
|
+
s2_collection: str
|
|
48
|
+
band_assets: dict[str, str]
|
|
49
|
+
preview_asset: str
|
|
50
|
+
sign: bool = False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
CATALOGS: dict[CatalogName, CatalogConfig] = {
|
|
54
|
+
CatalogName.PLANETARY_COMPUTER: CatalogConfig(
|
|
55
|
+
name=CatalogName.PLANETARY_COMPUTER,
|
|
56
|
+
url="https://planetarycomputer.microsoft.com/api/stac/v1",
|
|
57
|
+
title="Microsoft Planetary Computer",
|
|
58
|
+
s2_collection="sentinel-2-l2a",
|
|
59
|
+
band_assets={
|
|
60
|
+
"blue": "B02",
|
|
61
|
+
"green": "B03",
|
|
62
|
+
"red": "B04",
|
|
63
|
+
"nir": "B08",
|
|
64
|
+
"swir16": "B11",
|
|
65
|
+
"swir22": "B12",
|
|
66
|
+
"scl": "SCL",
|
|
67
|
+
},
|
|
68
|
+
preview_asset="rendered_preview",
|
|
69
|
+
sign=True,
|
|
70
|
+
),
|
|
71
|
+
CatalogName.EARTH_SEARCH: CatalogConfig(
|
|
72
|
+
name=CatalogName.EARTH_SEARCH,
|
|
73
|
+
url="https://earth-search.aws.element84.com/v1",
|
|
74
|
+
title="Element84 Earth Search",
|
|
75
|
+
s2_collection="sentinel-2-c1-l2a",
|
|
76
|
+
band_assets={b: b for b in CANONICAL_BANDS},
|
|
77
|
+
preview_asset="thumbnail",
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Short names an agent is likely to use for Sentinel-2 L2A.
|
|
82
|
+
S2_ALIASES = {"sentinel-2", "sentinel2", "s2", "sentinel-2-l2a", "s2-l2a", "sentinel-2-c1-l2a"}
|
|
83
|
+
|
|
84
|
+
# Item properties worth showing an LLM (the rest is noise).
|
|
85
|
+
DETAIL_PROPERTIES = (
|
|
86
|
+
"datetime",
|
|
87
|
+
"platform",
|
|
88
|
+
"constellation",
|
|
89
|
+
"eo:cloud_cover",
|
|
90
|
+
"s2:mgrs_tile",
|
|
91
|
+
"mgrs:utm_zone",
|
|
92
|
+
"proj:code",
|
|
93
|
+
"proj:epsg",
|
|
94
|
+
"s2:processing_baseline",
|
|
95
|
+
"s2:nodata_pixel_percentage",
|
|
96
|
+
"s2:vegetation_percentage",
|
|
97
|
+
"s2:water_percentage",
|
|
98
|
+
"s2:snow_ice_percentage",
|
|
99
|
+
"s2:high_proba_clouds_percentage",
|
|
100
|
+
"s2:cloud_shadow_percentage",
|
|
101
|
+
"sat:relative_orbit",
|
|
102
|
+
"sat:orbit_state",
|
|
103
|
+
"view:sun_elevation",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_config(catalog: CatalogName | str) -> CatalogConfig:
|
|
108
|
+
try:
|
|
109
|
+
return CATALOGS[CatalogName(catalog)]
|
|
110
|
+
except ValueError:
|
|
111
|
+
options = ", ".join(c.value for c in CatalogName)
|
|
112
|
+
raise InvalidInputError(
|
|
113
|
+
f"Unknown catalog '{catalog}'.", hint=f"Use one of: {options}."
|
|
114
|
+
) from None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@lru_cache
|
|
118
|
+
def get_client(catalog: CatalogName, signed: bool = True) -> Client:
|
|
119
|
+
"""STAC client; `signed` signs Planetary Computer asset URLs in results.
|
|
120
|
+
|
|
121
|
+
Listing collections must use signed=False: signing every collection costs one
|
|
122
|
+
token request each, which takes ~60 s on Planetary Computer instead of <1 s.
|
|
123
|
+
"""
|
|
124
|
+
cfg = get_config(catalog)
|
|
125
|
+
modifier = planetary_computer.sign_inplace if cfg.sign and signed else None
|
|
126
|
+
try:
|
|
127
|
+
log.info("Opening STAC catalog %s (signed=%s)", cfg.url, modifier is not None)
|
|
128
|
+
return Client.open(cfg.url, modifier=modifier)
|
|
129
|
+
except Exception as exc:
|
|
130
|
+
raise CatalogError(
|
|
131
|
+
f"Could not reach {cfg.title} ({cfg.url}): {exc}",
|
|
132
|
+
hint="Check the network connection, or try the other catalog.",
|
|
133
|
+
) from exc
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- Collections ----------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@lru_cache
|
|
140
|
+
def _collections(catalog: CatalogName) -> tuple[CollectionSummary, ...]:
|
|
141
|
+
client = get_client(catalog, signed=False)
|
|
142
|
+
try:
|
|
143
|
+
cols = list(client.get_collections())
|
|
144
|
+
except APIError as exc:
|
|
145
|
+
raise CatalogError(f"Listing collections failed: {exc}") from exc
|
|
146
|
+
return tuple(
|
|
147
|
+
CollectionSummary(id=c.id, title=c.title, description=_first_sentence(c.description))
|
|
148
|
+
for c in sorted(cols, key=lambda c: c.id)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def list_collections(catalog: CatalogName) -> list[CollectionSummary]:
|
|
153
|
+
return list(_collections(catalog))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_collection(catalog: CatalogName, collection: str) -> str:
|
|
157
|
+
"""Map S2 aliases to the catalog's collection and check that it exists."""
|
|
158
|
+
cfg = get_config(catalog)
|
|
159
|
+
collection = collection.strip()
|
|
160
|
+
if collection.lower() in S2_ALIASES:
|
|
161
|
+
return cfg.s2_collection
|
|
162
|
+
ids = [c.id for c in _collections(catalog)]
|
|
163
|
+
if collection in ids:
|
|
164
|
+
return collection
|
|
165
|
+
close = difflib.get_close_matches(collection, ids, n=3, cutoff=0.5)
|
|
166
|
+
hint = f"Did you mean: {', '.join(close)}?" if close else "Call list_collections to see ids."
|
|
167
|
+
raise InvalidInputError(f"Collection '{collection}' is not in {cfg.title}.", hint=hint)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- Search and lookup ----------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def search_items(
|
|
174
|
+
catalog: CatalogName,
|
|
175
|
+
collection: str,
|
|
176
|
+
bbox: BBox,
|
|
177
|
+
date_range: DateRange,
|
|
178
|
+
max_cloud_cover: float | None = None,
|
|
179
|
+
limit: int = 10,
|
|
180
|
+
) -> list[pystac.Item]:
|
|
181
|
+
"""Search scenes, newest first. Raises NoScenesError if none match."""
|
|
182
|
+
collection = resolve_collection(catalog, collection)
|
|
183
|
+
params: dict[str, Any] = {
|
|
184
|
+
"collections": [collection],
|
|
185
|
+
"bbox": bbox.as_list(),
|
|
186
|
+
"datetime": date_range.to_stac(),
|
|
187
|
+
"max_items": limit,
|
|
188
|
+
"sortby": [{"field": "properties.datetime", "direction": "desc"}],
|
|
189
|
+
}
|
|
190
|
+
if max_cloud_cover is not None:
|
|
191
|
+
params["query"] = {"eo:cloud_cover": {"lte": max_cloud_cover}}
|
|
192
|
+
try:
|
|
193
|
+
items = list(get_client(catalog).search(**params).items())
|
|
194
|
+
except APIError as exc:
|
|
195
|
+
raise CatalogError(f"Search failed on {get_config(catalog).title}: {exc}") from exc
|
|
196
|
+
if not items:
|
|
197
|
+
raise _no_scenes(collection, date_range, max_cloud_cover)
|
|
198
|
+
items.sort(key=lambda i: i.datetime or 0, reverse=True)
|
|
199
|
+
return items
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _no_scenes(collection: str, dr: DateRange, max_cc: float | None) -> NoScenesError:
|
|
203
|
+
if max_cc is not None:
|
|
204
|
+
msg = (
|
|
205
|
+
f"No {collection} scenes with cloud cover <= {max_cc:g}% "
|
|
206
|
+
f"between {dr.start} and {dr.end} for this bbox."
|
|
207
|
+
)
|
|
208
|
+
hint = "Raise max_cloud_cover or widen the date range."
|
|
209
|
+
else:
|
|
210
|
+
msg = f"No {collection} scenes between {dr.start} and {dr.end} for this bbox."
|
|
211
|
+
hint = "Widen the date range, or check the collection covers this area and period."
|
|
212
|
+
return NoScenesError(msg, hint=hint)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_item(catalog: CatalogName, collection: str, item_id: str) -> pystac.Item:
|
|
216
|
+
collection = resolve_collection(catalog, collection)
|
|
217
|
+
try:
|
|
218
|
+
items = list(
|
|
219
|
+
get_client(catalog).search(collections=[collection], ids=[item_id], max_items=1).items()
|
|
220
|
+
)
|
|
221
|
+
except APIError as exc:
|
|
222
|
+
raise CatalogError(f"Looking up '{item_id}' failed: {exc}") from exc
|
|
223
|
+
if not items:
|
|
224
|
+
raise NoScenesError(
|
|
225
|
+
f"Scene '{item_id}' was not found in {collection} on {get_config(catalog).title}.",
|
|
226
|
+
hint="Use an id returned by search_scenes, with the same catalog.",
|
|
227
|
+
)
|
|
228
|
+
return items[0]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --- Summaries ------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def summarize_item(item: pystac.Item, catalog: CatalogName) -> SceneSummary:
|
|
235
|
+
cfg = get_config(catalog)
|
|
236
|
+
preview = item.assets.get(cfg.preview_asset)
|
|
237
|
+
return SceneSummary(
|
|
238
|
+
id=item.id,
|
|
239
|
+
datetime=_iso(item),
|
|
240
|
+
cloud_cover=_round(item.properties.get("eo:cloud_cover")),
|
|
241
|
+
bbox=[round(v, 5) for v in item.bbox] if item.bbox else None,
|
|
242
|
+
footprint=simplify_geometry(item.geometry),
|
|
243
|
+
preview_url=preview.href if preview else None,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def describe_item(item: pystac.Item) -> SceneDetails:
|
|
248
|
+
props = {k: item.properties[k] for k in DETAIL_PROPERTIES if item.properties.get(k) is not None}
|
|
249
|
+
props.pop("datetime", None)
|
|
250
|
+
assets = [
|
|
251
|
+
AssetInfo(
|
|
252
|
+
key=key,
|
|
253
|
+
title=a.title,
|
|
254
|
+
media_type=a.media_type,
|
|
255
|
+
roles=a.roles or [],
|
|
256
|
+
gsd=a.extra_fields.get("gsd"),
|
|
257
|
+
)
|
|
258
|
+
for key, a in sorted(item.assets.items())
|
|
259
|
+
]
|
|
260
|
+
return SceneDetails(
|
|
261
|
+
id=item.id,
|
|
262
|
+
collection=item.collection_id or "",
|
|
263
|
+
datetime=_iso(item),
|
|
264
|
+
bbox=[round(v, 5) for v in item.bbox] if item.bbox else None,
|
|
265
|
+
properties=props,
|
|
266
|
+
assets=assets,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def simplify_geometry(geom: dict[str, Any] | None, tolerance: float = 0.001) -> dict | None:
|
|
271
|
+
"""Simplify (~100 m) and round a GeoJSON geometry to keep responses small."""
|
|
272
|
+
if not geom:
|
|
273
|
+
return None
|
|
274
|
+
simple = shape(geom).simplify(tolerance, preserve_topology=True)
|
|
275
|
+
return _round_coords(mapping(simple))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _round_coords(obj: Any, nd: int = 4) -> Any:
|
|
279
|
+
if isinstance(obj, float):
|
|
280
|
+
return round(obj, nd)
|
|
281
|
+
if isinstance(obj, list | tuple):
|
|
282
|
+
return [_round_coords(o, nd) for o in obj]
|
|
283
|
+
if isinstance(obj, dict):
|
|
284
|
+
return {k: _round_coords(v, nd) for k, v in obj.items()}
|
|
285
|
+
return obj
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --- Sentinel-2 bands -----------------------------------------------------
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True)
|
|
292
|
+
class BandScaling:
|
|
293
|
+
scale: float
|
|
294
|
+
offset_dn: float # added to DN before scaling
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def band_asset_keys(catalog: CatalogName, item: pystac.Item, bands: list[str]) -> dict[str, str]:
|
|
298
|
+
"""Canonical band -> asset key, checking that the item has each asset."""
|
|
299
|
+
cfg = get_config(catalog)
|
|
300
|
+
keys = {b: cfg.band_assets[b] for b in bands}
|
|
301
|
+
missing = [f"{b} ({k})" for b, k in keys.items() if k not in item.assets]
|
|
302
|
+
if missing:
|
|
303
|
+
raise DataUnavailableError(
|
|
304
|
+
f"Scene {item.id} lacks assets {missing}.",
|
|
305
|
+
hint=f"Only Sentinel-2 L2A scenes ({cfg.s2_collection}) are supported for indices.",
|
|
306
|
+
)
|
|
307
|
+
return keys
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def band_scaling(item: pystac.Item, asset_key: str) -> BandScaling:
|
|
311
|
+
"""DN -> reflectance parameters for a Sentinel-2 L2A band.
|
|
312
|
+
|
|
313
|
+
Prefer raster:bands scale/offset from the asset (Earth Search). Otherwise
|
|
314
|
+
fall back to the processing baseline (Planetary Computer): baseline 04.00
|
|
315
|
+
and later store DN with a +1000 offset.
|
|
316
|
+
"""
|
|
317
|
+
raster_bands = item.assets[asset_key].extra_fields.get("raster:bands") or []
|
|
318
|
+
if raster_bands and "scale" in raster_bands[0]:
|
|
319
|
+
scale = float(raster_bands[0]["scale"])
|
|
320
|
+
offset = float(raster_bands[0].get("offset", 0.0))
|
|
321
|
+
return BandScaling(scale=scale, offset_dn=offset / scale)
|
|
322
|
+
baseline = item.properties.get("s2:processing_baseline")
|
|
323
|
+
offset_dn = S2_BASELINE_OFFSET_DN if _baseline_ge(baseline, 4.0) else 0.0
|
|
324
|
+
return BandScaling(scale=S2_SCALE, offset_dn=offset_dn)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _baseline_ge(baseline: Any, threshold: float) -> bool:
|
|
328
|
+
try:
|
|
329
|
+
return float(baseline) >= threshold
|
|
330
|
+
except (TypeError, ValueError):
|
|
331
|
+
# Unknown baseline: assume modern data, which is the common case since 2022.
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# --- Helpers --------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _iso(item: pystac.Item) -> str | None:
|
|
339
|
+
return item.datetime.isoformat().replace("+00:00", "Z") if item.datetime else None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _round(v: Any, nd: int = 2) -> float | None:
|
|
343
|
+
return round(float(v), nd) if v is not None else None
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _first_sentence(text: str | None, max_len: int = 200) -> str | None:
|
|
347
|
+
if not text:
|
|
348
|
+
return None
|
|
349
|
+
text = " ".join(text.split())
|
|
350
|
+
cut = text.find(". ")
|
|
351
|
+
if 0 < cut < max_len:
|
|
352
|
+
return text[: cut + 1]
|
|
353
|
+
return text if len(text) <= max_len else text[: max_len - 1].rstrip() + "…"
|
stac_eo_mcp/config.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Settings read from environment variables (prefix STAC_EO_MCP_)."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from functools import lru_cache
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CatalogName(StrEnum):
|
|
12
|
+
PLANETARY_COMPUTER = "planetary-computer"
|
|
13
|
+
EARTH_SEARCH = "earth-search"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Settings(BaseSettings):
|
|
17
|
+
model_config = SettingsConfigDict(env_prefix="STAC_EO_MCP_", extra="ignore")
|
|
18
|
+
|
|
19
|
+
catalog: CatalogName = Field(
|
|
20
|
+
default=CatalogName.PLANETARY_COMPUTER,
|
|
21
|
+
description="Catalog used when a tool call does not name one.",
|
|
22
|
+
)
|
|
23
|
+
output_dir: Path = Field(
|
|
24
|
+
default=Path.home() / "stac-eo-mcp-output",
|
|
25
|
+
description="Folder where PNG/GeoTIFF outputs are written.",
|
|
26
|
+
)
|
|
27
|
+
max_area_km2: float = Field(
|
|
28
|
+
default=100.0, gt=0, description="Largest bbox accepted for raster reads."
|
|
29
|
+
)
|
|
30
|
+
max_scenes: int = Field(
|
|
31
|
+
default=24, ge=1, le=200, description="Most scenes read by time_series."
|
|
32
|
+
)
|
|
33
|
+
max_search_limit: int = Field(
|
|
34
|
+
default=50, ge=1, le=500, description="Most scenes returned by search_scenes."
|
|
35
|
+
)
|
|
36
|
+
log_level: str = Field(default="INFO", description="Python logging level (to stderr).")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@lru_cache
|
|
40
|
+
def get_settings() -> Settings:
|
|
41
|
+
return Settings()
|
stac_eo_mcp/errors.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Errors meant to be read by an AI agent.
|
|
2
|
+
|
|
3
|
+
Every error carries a short message (what went wrong) and an optional hint
|
|
4
|
+
(what to try next), so the agent can recover without guessing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StacMcpError(Exception):
|
|
9
|
+
"""Base error. str() gives 'message Hint: hint'."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, hint: str | None = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.hint = hint
|
|
15
|
+
|
|
16
|
+
def __str__(self) -> str:
|
|
17
|
+
if self.hint:
|
|
18
|
+
return f"{self.message} Hint: {self.hint}"
|
|
19
|
+
return self.message
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidInputError(StacMcpError):
|
|
23
|
+
"""A tool argument is malformed or out of range."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AreaTooLargeError(InvalidInputError):
|
|
27
|
+
"""The bbox exceeds the configured maximum area."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NoScenesError(StacMcpError):
|
|
31
|
+
"""A search matched no scenes."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CatalogError(StacMcpError):
|
|
35
|
+
"""The STAC catalog failed or returned something unusable."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class DataUnavailableError(StacMcpError):
|
|
39
|
+
"""A scene exists but lacks the assets or valid pixels needed."""
|