Add query_point, get_time_series tools and polar projection support
- query_point: reverse-maps pixel RGB through colormap to recover exact data values at geographic coordinates - get_time_series: fetches imagery across evenly-spaced dates for temporal analysis (up to 12 frames) - Auto-detect polar stereographic projection (EPSG:3413/3031) for high-latitude bounding boxes - Add progress reporting to all HTTP-calling tools - Add quantitative_snapshot and seasonal_timelapse prompts - Update README with 3 new conversational examples - 92 tests passing
This commit is contained in:
parent
ca7bc265f0
commit
ac6977a105
120
README.md
120
README.md
@ -54,6 +54,8 @@ claude mcp add mcgibs-local -- uv run --directory /path/to/mcgibs mcgibs
|
|||||||
| `get_imagery_composite` | Overlay up to 5 layers into a single composite image |
|
| `get_imagery_composite` | Overlay up to 5 layers into a single composite image |
|
||||||
| `explain_layer_colormap` | Natural-language explanation of what colors represent |
|
| `explain_layer_colormap` | Natural-language explanation of what colors represent |
|
||||||
| `get_legend` | Pre-rendered legend graphic for a layer |
|
| `get_legend` | Pre-rendered legend graphic for a layer |
|
||||||
|
| `query_point` | Get the exact data value at a coordinate by reverse-mapping the pixel color through the layer's colormap |
|
||||||
|
| `get_time_series` | Fetch imagery across multiple dates for temporal analysis (up to 12 frames) |
|
||||||
| `resolve_place` | Geocode a place name to coordinates and bounding box |
|
| `resolve_place` | Geocode a place name to coordinates and bounding box |
|
||||||
| `build_tile_url` | Construct a direct WMTS tile URL for embedding |
|
| `build_tile_url` | Construct a direct WMTS tile URL for embedding |
|
||||||
|
|
||||||
@ -78,6 +80,8 @@ claude mcp add mcgibs-local -- uv run --directory /path/to/mcgibs mcgibs
|
|||||||
| `layer_deep_dive` | `layer_id`, `location`, `date` | Full scientific analysis of a single layer |
|
| `layer_deep_dive` | `layer_id`, `location`, `date` | Full scientific analysis of a single layer |
|
||||||
| `multi_layer_story` | `topic`, `location`, `date` | Data journalism — composite layers to tell a story |
|
| `multi_layer_story` | `topic`, `location`, `date` | Data journalism — composite layers to tell a story |
|
||||||
| `polar_watch` | `pole`, `date`, `compare_date` | Arctic/Antarctic ice and snow monitoring |
|
| `polar_watch` | `pole`, `date`, `compare_date` | Arctic/Antarctic ice and snow monitoring |
|
||||||
|
| `quantitative_snapshot` | `layer_id`, `locations`, `date` | Query exact data values at specific coordinates |
|
||||||
|
| `seasonal_timelapse` | `layer_id`, `location`, `start_date`, `end_date` | Track visual changes across multiple dates |
|
||||||
|
|
||||||
## Conversational Examples
|
## Conversational Examples
|
||||||
|
|
||||||
@ -230,6 +234,122 @@ The assistant searches for complementary layers, checks dates, and builds a comp
|
|||||||
|
|
||||||
> **Assistant:** The composite overlay tells the story clearly. Over the LA basin, the true color base shows a visible smoke plume extending southwest over the Pacific. The aerosol optical depth overlay confirms it — the orange-to-red values (0.5-1.0) directly over the fire zone indicate thick particulate loading, fading to yellow-green (0.15, clean air) further out over the ocean. The fire thermal anomaly layer pinpoints the active burn areas as bright red hotspots in the foothills.
|
> **Assistant:** The composite overlay tells the story clearly. Over the LA basin, the true color base shows a visible smoke plume extending southwest over the Pacific. The aerosol optical depth overlay confirms it — the orange-to-red values (0.5-1.0) directly over the fire zone indicate thick particulate loading, fading to yellow-green (0.15, clean air) further out over the ocean. The fire thermal anomaly layer pinpoints the active burn areas as bright red hotspots in the foothills.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Querying exact values at a point
|
||||||
|
|
||||||
|
*Try this with the `quantitative_snapshot` prompt: `layer_id="AIRS_L3_Surface_Air_Temperature_Daily_Day", locations="Tokyo, Sydney, Sahara Desert", date="2025-07-15"`*
|
||||||
|
|
||||||
|
> **You:** What's the actual surface temperature in Tokyo, Sydney, and the Sahara right now?
|
||||||
|
|
||||||
|
The assistant queries exact data values by reverse-mapping pixel colors through the layer's colormap:
|
||||||
|
|
||||||
|
```
|
||||||
|
→ explain_layer_colormap("AIRS_L3_Surface_Air_Temperature_Daily_Day")
|
||||||
|
Surface Air Temperature (K):
|
||||||
|
- light blue = below 200 K (-73.1 C)
|
||||||
|
- blue = ~260 K (-13.4 C)
|
||||||
|
- orange = ~290 K (17.1 C)
|
||||||
|
- magenta = above 320 K (46.9 C)
|
||||||
|
(242 color stops total, 5 key values shown)
|
||||||
|
|
||||||
|
→ query_point("AIRS_L3_Surface_Air_Temperature_Daily_Day", "2025-07-15",
|
||||||
|
lat=35.68, lon=139.69)
|
||||||
|
{"value": 299.2, "units": "K", "converted": "26.1 C",
|
||||||
|
"color": "orange", "quality": "exact",
|
||||||
|
"description": "mild"}
|
||||||
|
|
||||||
|
→ query_point(..., lat=-33.87, lon=151.21)
|
||||||
|
{"value": 282.8, "units": "K", "converted": "9.6 C",
|
||||||
|
"color": "blue", "quality": "exact",
|
||||||
|
"description": "near freezing"}
|
||||||
|
|
||||||
|
→ query_point(..., lat=25.0, lon=25.0)
|
||||||
|
{"value": 304.8, "units": "K", "converted": "31.6 C",
|
||||||
|
"color": "red", "quality": "exact",
|
||||||
|
"description": "hot"}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Assistant:** Here are the AIRS-measured surface air temperatures for July 15, 2025:
|
||||||
|
>
|
||||||
|
> | Location | Temperature | Condition |
|
||||||
|
> |----------|------------|-----------|
|
||||||
|
> | Tokyo | 26.1°C (299.2 K) | Mild summer day |
|
||||||
|
> | Sydney | 9.6°C (282.8 K) | Cool — it's winter in the Southern Hemisphere |
|
||||||
|
> | Sahara | 31.6°C (304.8 K) | Hot, typical for desert in July |
|
||||||
|
>
|
||||||
|
> These come from the Atmospheric Infrared Sounder (AIRS) on NASA's Aqua satellite. The resolution is about 0.5°C — each of the 242 colormap entries covers a narrow value range, so the reverse-mapped pixel color gives an exact match to the original data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Watching a season unfold
|
||||||
|
|
||||||
|
*Try this with the `seasonal_timelapse` prompt: `layer_id="MODIS_Terra_NDVI_8Day", location="Great Plains, USA", start_date="2025-03-01", end_date="2025-09-01"`*
|
||||||
|
|
||||||
|
> **You:** Show me how vegetation changes across the US Great Plains from winter to summer.
|
||||||
|
|
||||||
|
The assistant fetches a sequence of images at regular intervals:
|
||||||
|
|
||||||
|
```
|
||||||
|
→ search_gibs_layers("NDVI")
|
||||||
|
MODIS_Terra_NDVI_8Day
|
||||||
|
MODIS/Terra Vegetation Index (NDVI), 8-Day — from 2000-02-18 (8-Day)
|
||||||
|
[has colormap]
|
||||||
|
|
||||||
|
→ explain_layer_colormap("MODIS_Terra_NDVI_8Day")
|
||||||
|
Vegetation Index (NDVI):
|
||||||
|
- tan/brown = ~0.1 (bare soil, dormant vegetation)
|
||||||
|
- yellow-green = ~0.3 (sparse vegetation)
|
||||||
|
- green = ~0.6 (moderate vegetation)
|
||||||
|
- dark green = ~0.9 (dense, healthy vegetation)
|
||||||
|
|
||||||
|
→ get_time_series("MODIS_Terra_NDVI_8Day",
|
||||||
|
place="Great Plains USA",
|
||||||
|
start_date="2025-03-01",
|
||||||
|
end_date="2025-09-01",
|
||||||
|
steps=6)
|
||||||
|
[6 images: Mar 1, Apr 13, May 25, Jul 6, Aug 17, Sep 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Assistant:** The six frames tell a clear story of the growing season:
|
||||||
|
>
|
||||||
|
> 1. **March** — The Plains are mostly tan/brown (NDVI ~0.1), winter dormancy. Only southern Texas shows hints of green.
|
||||||
|
> 2. **April** — A flush of yellow-green creeps north from Oklahoma as winter wheat greens up.
|
||||||
|
> 3. **May** — The green wave accelerates — Kansas and Nebraska shift to solid green (NDVI ~0.5) as corn and soybean planting peaks.
|
||||||
|
> 4. **July** — Peak vegetation. The entire region is dark green (NDVI 0.7-0.9). Crop canopy is fully closed.
|
||||||
|
> 5. **August** — Still green but beginning to yellow at the margins. Early harvest in southern counties.
|
||||||
|
> 6. **September** — Harvest underway. The palette shifts back toward yellow-green and tan as crops are cut.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Polar ice in proper projection
|
||||||
|
|
||||||
|
*Try this with the `polar_watch` prompt: `pole="arctic", date="2025-09-01"`*
|
||||||
|
|
||||||
|
> **You:** Show me the current state of Arctic sea ice — and use the right map projection.
|
||||||
|
|
||||||
|
The `polar_watch` prompt guides the assistant to use polar stereographic projection:
|
||||||
|
|
||||||
|
```
|
||||||
|
→ get_imagery("AMSRU2_Sea_Ice_Concentration_12km", "2025-09-01",
|
||||||
|
bbox=[-180, 60, 180, 90],
|
||||||
|
projection="3413")
|
||||||
|
[image: Arctic sea ice in polar stereographic — circular view centered on the pole]
|
||||||
|
|
||||||
|
→ explain_layer_colormap("AMSRU2_Sea_Ice_Concentration_12km")
|
||||||
|
Sea Ice Concentration (%):
|
||||||
|
- dark blue = ~15% (scattered floes)
|
||||||
|
- cyan = ~40%
|
||||||
|
- light grey = ~70%
|
||||||
|
- white = ~100% (solid pack ice)
|
||||||
|
|
||||||
|
→ build_tile_url("AMSRU2_Sea_Ice_Concentration_12km", "2025-09-01",
|
||||||
|
zoom=2, row=0, col=0, projection="3413")
|
||||||
|
https://gibs.earthdata.nasa.gov/wmts/epsg3413/best/AMSRU2_Sea_Ice_Concentration_12km/...
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Assistant:** With polar stereographic projection (EPSG:3413), the Arctic is shown as a circular view centered on the North Pole — no more equirectangular distortion. The September minimum is clearly visible: solid pack ice (white, 100%) is confined to a shrinking core, surrounded by a wide margin of partial ice (blues and cyans, 15-40%). The embeddable WMTS tile URL is ready for dashboards or reports.
|
||||||
|
|
||||||
## Projections
|
## Projections
|
||||||
|
|
||||||
| EPSG | Description | Use case |
|
| EPSG | Description | Use case |
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcgibs"
|
name = "mcgibs"
|
||||||
version = "2026.02.18.4"
|
version = "2026.02.19"
|
||||||
description = "FastMCP server for NASA Global Imagery Browse Services (GIBS)"
|
description = "FastMCP server for NASA Global Imagery Browse Services (GIBS)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
__version__ = "2026.02.18.4"
|
__version__ = "2026.02.19"
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import httpx
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from mcgibs.capabilities import parse_capabilities
|
from mcgibs.capabilities import parse_capabilities
|
||||||
from mcgibs.colormaps import explain_colormap, parse_colormap
|
from mcgibs.colormaps import explain_colormap, parse_colormap, reverse_lookup
|
||||||
from mcgibs.constants import (
|
from mcgibs.constants import (
|
||||||
COLORMAP_BASE,
|
COLORMAP_BASE,
|
||||||
DEFAULT_EPSG,
|
DEFAULT_EPSG,
|
||||||
@ -296,6 +296,7 @@ class GIBSClient:
|
|||||||
width: int = 1024,
|
width: int = 1024,
|
||||||
height: int = 1024,
|
height: int = 1024,
|
||||||
image_format: str = "image/jpeg",
|
image_format: str = "image/jpeg",
|
||||||
|
*,
|
||||||
epsg: str = DEFAULT_EPSG,
|
epsg: str = DEFAULT_EPSG,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Fetch a multi-layer WMS composite image."""
|
"""Fetch a multi-layer WMS composite image."""
|
||||||
@ -318,6 +319,7 @@ class GIBSClient:
|
|||||||
width: int = 1024,
|
width: int = 1024,
|
||||||
height: int = 512,
|
height: int = 512,
|
||||||
image_format: str = "image/jpeg",
|
image_format: str = "image/jpeg",
|
||||||
|
epsg: str = DEFAULT_EPSG,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Fetch two images and compose a side-by-side comparison."""
|
"""Fetch two images and compose a side-by-side comparison."""
|
||||||
img_before = await self.get_wms_image(
|
img_before = await self.get_wms_image(
|
||||||
@ -327,6 +329,7 @@ class GIBSClient:
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
image_format,
|
image_format,
|
||||||
|
epsg,
|
||||||
)
|
)
|
||||||
img_after = await self.get_wms_image(
|
img_after = await self.get_wms_image(
|
||||||
layer_id,
|
layer_id,
|
||||||
@ -335,6 +338,7 @@ class GIBSClient:
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
image_format,
|
image_format,
|
||||||
|
epsg,
|
||||||
)
|
)
|
||||||
|
|
||||||
# M1: convert to RGB to avoid mode mismatch with RGBA PNGs
|
# M1: convert to RGB to avoid mode mismatch with RGBA PNGs
|
||||||
@ -351,6 +355,66 @@ class GIBSClient:
|
|||||||
composite.save(buf, format="JPEG", quality=90)
|
composite.save(buf, format="JPEG", quality=90)
|
||||||
return buf.getvalue()
|
return buf.getvalue()
|
||||||
|
|
||||||
|
# --- Point query (RGB reverse-mapping) ---
|
||||||
|
|
||||||
|
async def query_point(
|
||||||
|
self,
|
||||||
|
layer_id: str,
|
||||||
|
date: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
epsg: str = DEFAULT_EPSG,
|
||||||
|
) -> dict:
|
||||||
|
"""Query the data value at a geographic point via RGB reverse-mapping.
|
||||||
|
|
||||||
|
Fetches a 1x1 pixel PNG at the given coordinate and maps the pixel
|
||||||
|
color back through the layer's colormap to recover the data value.
|
||||||
|
"""
|
||||||
|
# Small bbox centered on the point (0.5 degree padding, 3x3 pixels)
|
||||||
|
bbox = BBox(west=lon - 0.5, south=lat - 0.5, east=lon + 0.5, north=lat + 0.5)
|
||||||
|
img_bytes = await self.get_wms_image(
|
||||||
|
layer_id, date, bbox, 3, 3, "image/png", epsg
|
||||||
|
)
|
||||||
|
|
||||||
|
img = Image.open(BytesIO(img_bytes)).convert("RGBA")
|
||||||
|
r, g, b, a = img.getpixel((1, 1))
|
||||||
|
|
||||||
|
if a == 0:
|
||||||
|
return {"nodata": True, "label": "No Data (transparent pixel)"}
|
||||||
|
|
||||||
|
colormap_set = await self.fetch_colormap(layer_id)
|
||||||
|
if colormap_set is None:
|
||||||
|
return {
|
||||||
|
"color_rgb": [r, g, b],
|
||||||
|
"note": "No colormap available — cannot reverse-map to data value",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = reverse_lookup(colormap_set, (r, g, b))
|
||||||
|
result["lat"] = lat
|
||||||
|
result["lon"] = lon
|
||||||
|
return result
|
||||||
|
|
||||||
|
# --- Time series ---
|
||||||
|
|
||||||
|
async def get_time_series_images(
|
||||||
|
self,
|
||||||
|
layer_id: str,
|
||||||
|
bbox: BBox,
|
||||||
|
dates: list[str],
|
||||||
|
width: int = 512,
|
||||||
|
height: int = 512,
|
||||||
|
image_format: str = "image/jpeg",
|
||||||
|
epsg: str = DEFAULT_EPSG,
|
||||||
|
) -> list[tuple[str, bytes]]:
|
||||||
|
"""Fetch imagery for multiple dates, returning (date, image_bytes) pairs."""
|
||||||
|
results = []
|
||||||
|
for date in dates:
|
||||||
|
img = await self.get_wms_image(
|
||||||
|
layer_id, date, bbox, width, height, image_format, epsg
|
||||||
|
)
|
||||||
|
results.append((date, img))
|
||||||
|
return results
|
||||||
|
|
||||||
# --- DescribeDomains (date ranges) ---
|
# --- DescribeDomains (date ranges) ---
|
||||||
|
|
||||||
async def describe_domains(
|
async def describe_domains(
|
||||||
|
|||||||
@ -484,3 +484,69 @@ def explain_colormap(colormap_set: ColorMapSet) -> str:
|
|||||||
lines.append(f"Transparent / no-data: {', '.join(nodata_entries)}")
|
lines.append(f"Transparent / no-data: {', '.join(nodata_entries)}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_lookup(
|
||||||
|
colormap_set: ColorMapSet, rgb: tuple[int, int, int]
|
||||||
|
) -> dict:
|
||||||
|
"""Map an RGB pixel value back to its data value using the colormap.
|
||||||
|
|
||||||
|
Finds the colormap entry with the closest RGB match and returns
|
||||||
|
the associated data value, units, and qualitative description.
|
||||||
|
"""
|
||||||
|
data_map = colormap_set.data_map
|
||||||
|
if data_map is None:
|
||||||
|
return {"error": "No colormap data available"}
|
||||||
|
|
||||||
|
# Check for no-data / transparent match first
|
||||||
|
for entry in data_map.entries:
|
||||||
|
if (entry.nodata or entry.transparent) and entry.rgb == rgb:
|
||||||
|
return {"nodata": True, "label": entry.label or "No Data"}
|
||||||
|
|
||||||
|
# Find closest data entry by RGB distance
|
||||||
|
best_dist = float("inf")
|
||||||
|
best_entry = None
|
||||||
|
for entry in data_map.entries:
|
||||||
|
if entry.transparent or entry.nodata:
|
||||||
|
continue
|
||||||
|
dr = entry.rgb[0] - rgb[0]
|
||||||
|
dg = entry.rgb[1] - rgb[1]
|
||||||
|
db = entry.rgb[2] - rgb[2]
|
||||||
|
dist = dr * dr + dg * dg + db * db
|
||||||
|
if dist < best_dist:
|
||||||
|
best_dist = dist
|
||||||
|
best_entry = entry
|
||||||
|
|
||||||
|
if best_entry is None:
|
||||||
|
return {"error": "No matching colormap entry"}
|
||||||
|
|
||||||
|
units = data_map.units or ""
|
||||||
|
color_name = _describe_rgb(best_entry.rgb)
|
||||||
|
result: dict = {
|
||||||
|
"color": color_name,
|
||||||
|
"quality": "exact" if best_dist == 0 else "approximate",
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_entry.label:
|
||||||
|
result["label"] = best_entry.label
|
||||||
|
return result
|
||||||
|
|
||||||
|
low, high = _parse_interval_value(best_entry.value or "")
|
||||||
|
if low is not None and high is not None:
|
||||||
|
mid = (low + high) / 2
|
||||||
|
result["value"] = round(mid, 2)
|
||||||
|
if units:
|
||||||
|
result["units"] = units
|
||||||
|
converter_info = _UNIT_CONVERTERS.get(units.lower().strip())
|
||||||
|
if converter_info:
|
||||||
|
converter, target_unit = converter_info
|
||||||
|
result["converted"] = f"{converter(mid):.1f} {target_unit}"
|
||||||
|
is_temp = units.lower().strip() in ("k", "kelvin")
|
||||||
|
if is_temp:
|
||||||
|
result["description"] = _describe_temperature_feel(_kelvin_to_celsius(mid))
|
||||||
|
elif low is not None:
|
||||||
|
result["value"] = round(low, 2)
|
||||||
|
if units:
|
||||||
|
result["units"] = units
|
||||||
|
|
||||||
|
return result
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import asyncio
|
|||||||
import atexit
|
import atexit
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import date as Date
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
@ -159,7 +160,7 @@ async def search_gibs_layers(
|
|||||||
description="Get detailed metadata for a specific GIBS layer including "
|
description="Get detailed metadata for a specific GIBS layer including "
|
||||||
"dates, resolution, formats, projections, and colormap availability."
|
"dates, resolution, formats, projections, and colormap availability."
|
||||||
)
|
)
|
||||||
async def get_layer_info(layer_id: str) -> str:
|
async def get_layer_info(layer_id: str, ctx: Context) -> str:
|
||||||
"""Fetch full metadata for a layer.
|
"""Fetch full metadata for a layer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -170,7 +171,7 @@ async def get_layer_info(layer_id: str) -> str:
|
|||||||
if layer is None:
|
if layer is None:
|
||||||
return f"Layer '{layer_id}' not found. Use search_gibs_layers to find valid identifiers."
|
return f"Layer '{layer_id}' not found. Use search_gibs_layers to find valid identifiers."
|
||||||
|
|
||||||
# Enrich with layer-metadata JSON
|
await ctx.report_progress(1, 2, "Fetching layer metadata...")
|
||||||
await client.fetch_layer_metadata(layer_id)
|
await client.fetch_layer_metadata(layer_id)
|
||||||
|
|
||||||
info = {
|
info = {
|
||||||
@ -212,6 +213,7 @@ async def get_layer_info(layer_id: str) -> str:
|
|||||||
"north": layer.bbox.north,
|
"north": layer.bbox.north,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ctx.report_progress(2, 2, "Complete")
|
||||||
return json.dumps(info, indent=2)
|
return json.dumps(info, indent=2)
|
||||||
|
|
||||||
|
|
||||||
@ -240,7 +242,7 @@ async def list_measurements() -> str:
|
|||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Check available date ranges for a GIBS layer via WMTS DescribeDomains.")
|
@mcp.tool(description="Check available date ranges for a GIBS layer via WMTS DescribeDomains.")
|
||||||
async def check_layer_dates(layer_id: str) -> str:
|
async def check_layer_dates(layer_id: str, ctx: Context) -> str:
|
||||||
"""Query what dates are available for a specific layer.
|
"""Query what dates are available for a specific layer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -267,6 +269,7 @@ async def check_layer_dates(layer_id: str) -> str:
|
|||||||
lines.append(" No time dimension (static layer)")
|
lines.append(" No time dimension (static layer)")
|
||||||
|
|
||||||
# Try DescribeDomains for more precise info
|
# Try DescribeDomains for more precise info
|
||||||
|
await ctx.report_progress(1, 2, "Querying live date range...")
|
||||||
try:
|
try:
|
||||||
domains = await client.describe_domains(layer_id)
|
domains = await client.describe_domains(layer_id)
|
||||||
if "time_domain" in domains:
|
if "time_domain" in domains:
|
||||||
@ -274,6 +277,7 @@ async def check_layer_dates(layer_id: str) -> str:
|
|||||||
except (httpx.HTTPError, RuntimeError) as exc:
|
except (httpx.HTTPError, RuntimeError) as exc:
|
||||||
log.debug("DescribeDomains failed for %s: %s", layer_id, exc)
|
log.debug("DescribeDomains failed for %s: %s", layer_id, exc)
|
||||||
|
|
||||||
|
await ctx.report_progress(2, 2, "Complete")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@ -300,6 +304,29 @@ async def _resolve_bbox(
|
|||||||
raise ValueError("Provide either 'bbox' [west, south, east, north] or 'place' name.")
|
raise ValueError("Provide either 'bbox' [west, south, east, north] or 'place' name.")
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_projection(bbox: BBox, projection: str) -> str:
|
||||||
|
"""Resolve projection — auto-detect polar if set to 'auto'."""
|
||||||
|
if projection != "auto":
|
||||||
|
return projection
|
||||||
|
if bbox.south >= 55:
|
||||||
|
return "3413"
|
||||||
|
if bbox.north <= -55:
|
||||||
|
return "3031"
|
||||||
|
return "4326"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_dates(start: str, end: str, steps: int) -> list[str]:
|
||||||
|
"""Generate evenly-spaced dates between start and end (inclusive)."""
|
||||||
|
d0 = Date.fromisoformat(start)
|
||||||
|
d1 = Date.fromisoformat(end)
|
||||||
|
if steps <= 1:
|
||||||
|
return [start]
|
||||||
|
if steps == 2:
|
||||||
|
return [start, end]
|
||||||
|
delta = (d1 - d0) / (steps - 1)
|
||||||
|
return [(d0 + delta * i).isoformat() for i in range(steps)]
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
description="Fetch satellite imagery for a specific layer, date, and region. "
|
description="Fetch satellite imagery for a specific layer, date, and region. "
|
||||||
"Provide either a place name or bbox coordinates. Returns the image."
|
"Provide either a place name or bbox coordinates. Returns the image."
|
||||||
@ -313,6 +340,7 @@ async def get_imagery(
|
|||||||
width: int = 1024,
|
width: int = 1024,
|
||||||
height: int = 1024,
|
height: int = 1024,
|
||||||
format: str = "jpeg",
|
format: str = "jpeg",
|
||||||
|
projection: str = "auto",
|
||||||
):
|
):
|
||||||
"""Fetch GIBS imagery via WMS.
|
"""Fetch GIBS imagery via WMS.
|
||||||
|
|
||||||
@ -324,6 +352,7 @@ async def get_imagery(
|
|||||||
width: Image width in pixels.
|
width: Image width in pixels.
|
||||||
height: Image height in pixels.
|
height: Image height in pixels.
|
||||||
format: Image format — "jpeg" or "png".
|
format: Image format — "jpeg" or "png".
|
||||||
|
projection: EPSG code ("4326", "3857", "3413", "3031") or "auto" to detect from bbox.
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
@ -337,6 +366,8 @@ async def get_imagery(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
|
|
||||||
|
epsg = _pick_projection(resolved_bbox, projection)
|
||||||
|
|
||||||
await ctx.report_progress(2, 4, "Fetching imagery from GIBS...")
|
await ctx.report_progress(2, 4, "Fetching imagery from GIBS...")
|
||||||
image_bytes = await client.get_wms_image(
|
image_bytes = await client.get_wms_image(
|
||||||
layer_id,
|
layer_id,
|
||||||
@ -345,6 +376,7 @@ async def get_imagery(
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
f"image/{format}",
|
f"image/{format}",
|
||||||
|
epsg,
|
||||||
)
|
)
|
||||||
|
|
||||||
description = (
|
description = (
|
||||||
@ -370,6 +402,7 @@ async def compare_dates(
|
|||||||
ctx: Context,
|
ctx: Context,
|
||||||
bbox: list[float] | None = None,
|
bbox: list[float] | None = None,
|
||||||
place: str | None = None,
|
place: str | None = None,
|
||||||
|
projection: str = "auto",
|
||||||
):
|
):
|
||||||
"""Side-by-side comparison of two dates.
|
"""Side-by-side comparison of two dates.
|
||||||
|
|
||||||
@ -379,6 +412,7 @@ async def compare_dates(
|
|||||||
date_after: Later date (YYYY-MM-DD).
|
date_after: Later date (YYYY-MM-DD).
|
||||||
bbox: Bounding box as [west, south, east, north].
|
bbox: Bounding box as [west, south, east, north].
|
||||||
place: Place name to geocode.
|
place: Place name to geocode.
|
||||||
|
projection: EPSG code or "auto" to detect from bbox.
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
@ -392,6 +426,8 @@ async def compare_dates(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
|
|
||||||
|
epsg = _pick_projection(resolved_bbox, projection)
|
||||||
|
|
||||||
await ctx.report_progress(2, 5, f"Fetching imagery for {date_before}...")
|
await ctx.report_progress(2, 5, f"Fetching imagery for {date_before}...")
|
||||||
await ctx.report_progress(3, 5, f"Fetching imagery for {date_after}...")
|
await ctx.report_progress(3, 5, f"Fetching imagery for {date_after}...")
|
||||||
composite_bytes = await client.compare_dates(
|
composite_bytes = await client.compare_dates(
|
||||||
@ -399,6 +435,8 @@ async def compare_dates(
|
|||||||
date_before,
|
date_before,
|
||||||
date_after,
|
date_after,
|
||||||
resolved_bbox,
|
resolved_bbox,
|
||||||
|
image_format="image/jpeg",
|
||||||
|
epsg=epsg,
|
||||||
)
|
)
|
||||||
|
|
||||||
description = (
|
description = (
|
||||||
@ -423,6 +461,7 @@ async def get_imagery_composite(
|
|||||||
place: str | None = None,
|
place: str | None = None,
|
||||||
width: int = 1024,
|
width: int = 1024,
|
||||||
height: int = 1024,
|
height: int = 1024,
|
||||||
|
projection: str = "auto",
|
||||||
):
|
):
|
||||||
"""Multi-layer composite image.
|
"""Multi-layer composite image.
|
||||||
|
|
||||||
@ -433,6 +472,7 @@ async def get_imagery_composite(
|
|||||||
place: Place name to geocode.
|
place: Place name to geocode.
|
||||||
width: Image width in pixels.
|
width: Image width in pixels.
|
||||||
height: Image height in pixels.
|
height: Image height in pixels.
|
||||||
|
projection: EPSG code or "auto" to detect from bbox.
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
@ -445,6 +485,8 @@ async def get_imagery_composite(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return str(exc)
|
return str(exc)
|
||||||
|
|
||||||
|
epsg = _pick_projection(resolved_bbox, projection)
|
||||||
|
|
||||||
layer_names = ", ".join(layer_ids)
|
layer_names = ", ".join(layer_ids)
|
||||||
await ctx.report_progress(2, 3, f"Fetching {len(layer_ids)}-layer composite...")
|
await ctx.report_progress(2, 3, f"Fetching {len(layer_ids)}-layer composite...")
|
||||||
image_bytes = await client.get_wms_composite(
|
image_bytes = await client.get_wms_composite(
|
||||||
@ -453,6 +495,7 @@ async def get_imagery_composite(
|
|||||||
resolved_bbox,
|
resolved_bbox,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
epsg=epsg,
|
||||||
)
|
)
|
||||||
|
|
||||||
description = (
|
description = (
|
||||||
@ -472,19 +515,23 @@ async def get_imagery_composite(
|
|||||||
description="Explain what the colors in a GIBS layer mean. "
|
description="Explain what the colors in a GIBS layer mean. "
|
||||||
"Returns a natural-language description mapping colors to scientific values and units."
|
"Returns a natural-language description mapping colors to scientific values and units."
|
||||||
)
|
)
|
||||||
async def explain_layer_colormap(layer_id: str) -> str:
|
async def explain_layer_colormap(layer_id: str, ctx: Context) -> str:
|
||||||
"""Get natural-language explanation of a layer's colormap.
|
"""Get natural-language explanation of a layer's colormap.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
layer_id: GIBS layer identifier.
|
layer_id: GIBS layer identifier.
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
return await client.explain_layer_colormap(layer_id)
|
await ctx.report_progress(1, 2, "Fetching colormap...")
|
||||||
|
result = await client.explain_layer_colormap(layer_id)
|
||||||
|
await ctx.report_progress(2, 2, "Complete")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool(description="Fetch the pre-rendered legend image for a GIBS layer.")
|
@mcp.tool(description="Fetch the pre-rendered legend image for a GIBS layer.")
|
||||||
async def get_legend(
|
async def get_legend(
|
||||||
layer_id: str,
|
layer_id: str,
|
||||||
|
ctx: Context,
|
||||||
orientation: str = "horizontal",
|
orientation: str = "horizontal",
|
||||||
):
|
):
|
||||||
"""Fetch the legend graphic for a layer.
|
"""Fetch the legend graphic for a layer.
|
||||||
@ -494,14 +541,113 @@ async def get_legend(
|
|||||||
orientation: "horizontal" or "vertical".
|
orientation: "horizontal" or "vertical".
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
await ctx.report_progress(1, 2, "Fetching legend...")
|
||||||
legend_bytes = await client.get_legend_image(layer_id, orientation)
|
legend_bytes = await client.get_legend_image(layer_id, orientation)
|
||||||
|
|
||||||
if legend_bytes is None:
|
if legend_bytes is None:
|
||||||
return f"No legend available for '{layer_id}'."
|
return f"No legend available for '{layer_id}'."
|
||||||
|
|
||||||
|
await ctx.report_progress(2, 2, "Complete")
|
||||||
return [f"Legend for {layer_id}", Image(data=legend_bytes, format="png")]
|
return [f"Legend for {layer_id}", Image(data=legend_bytes, format="png")]
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Get the exact data value at a geographic coordinate. "
|
||||||
|
"Reverse-maps the pixel color through the layer's colormap to recover "
|
||||||
|
"the numeric value with units. Only works for layers with colormaps."
|
||||||
|
)
|
||||||
|
async def query_point(
|
||||||
|
layer_id: str,
|
||||||
|
date: str,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
ctx: Context,
|
||||||
|
) -> str:
|
||||||
|
"""Query the data value at a specific point.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer_id: GIBS layer identifier (must have a colormap).
|
||||||
|
date: Date in YYYY-MM-DD format.
|
||||||
|
lat: Latitude in decimal degrees.
|
||||||
|
lon: Longitude in decimal degrees.
|
||||||
|
"""
|
||||||
|
client = _get_client()
|
||||||
|
|
||||||
|
layer = client.get_layer(layer_id)
|
||||||
|
if layer is None:
|
||||||
|
return f"Layer '{layer_id}' not found."
|
||||||
|
if not layer.has_colormap:
|
||||||
|
return f"Layer '{layer_id}' has no colormap — cannot reverse-map pixel values."
|
||||||
|
|
||||||
|
await ctx.report_progress(1, 3, "Fetching pixel...")
|
||||||
|
result = await client.query_point(layer_id, date, lat, lon)
|
||||||
|
await ctx.report_progress(3, 3, "Complete")
|
||||||
|
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
description="Fetch imagery for a layer across multiple dates. "
|
||||||
|
"Returns a sequence of images at regular intervals between start and end dates. "
|
||||||
|
"Useful for observing temporal progression (up to 12 frames)."
|
||||||
|
)
|
||||||
|
async def get_time_series(
|
||||||
|
layer_id: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
ctx: Context,
|
||||||
|
bbox: list[float] | None = None,
|
||||||
|
place: str | None = None,
|
||||||
|
steps: int = 6,
|
||||||
|
width: int = 512,
|
||||||
|
height: int = 512,
|
||||||
|
projection: str = "auto",
|
||||||
|
):
|
||||||
|
"""Fetch imagery for multiple dates in a range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer_id: GIBS layer identifier.
|
||||||
|
start_date: First date (YYYY-MM-DD).
|
||||||
|
end_date: Last date (YYYY-MM-DD).
|
||||||
|
bbox: Bounding box as [west, south, east, north].
|
||||||
|
place: Place name to geocode.
|
||||||
|
steps: Number of evenly-spaced frames (2-12).
|
||||||
|
width: Image width in pixels.
|
||||||
|
height: Image height in pixels.
|
||||||
|
projection: EPSG code or "auto" to detect from bbox.
|
||||||
|
"""
|
||||||
|
client = _get_client()
|
||||||
|
|
||||||
|
layer = client.get_layer(layer_id)
|
||||||
|
if layer is None:
|
||||||
|
return f"Layer '{layer_id}' not found."
|
||||||
|
|
||||||
|
steps = max(2, min(steps, 12))
|
||||||
|
dates = _generate_dates(start_date, end_date, steps)
|
||||||
|
|
||||||
|
await ctx.report_progress(1, steps + 2, "Resolving location...")
|
||||||
|
try:
|
||||||
|
resolved_bbox = await _resolve_bbox(client, bbox, place)
|
||||||
|
except Exception as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
epsg = _pick_projection(resolved_bbox, projection)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, date in enumerate(dates):
|
||||||
|
await ctx.report_progress(
|
||||||
|
i + 2, steps + 2, f"Fetching {date} ({i + 1}/{len(dates)})..."
|
||||||
|
)
|
||||||
|
image_bytes = await client.get_wms_image(
|
||||||
|
layer_id, date, resolved_bbox, width, height, "image/jpeg", epsg
|
||||||
|
)
|
||||||
|
results.append(f"{layer.title} — {date}")
|
||||||
|
results.append(Image(data=image_bytes, format="jpeg"))
|
||||||
|
|
||||||
|
await ctx.report_progress(steps + 2, steps + 2, "Complete")
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
# TOOLS — Utility
|
# TOOLS — Utility
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
@ -511,18 +657,20 @@ async def get_legend(
|
|||||||
description="Geocode a place name to geographic coordinates and bounding box. "
|
description="Geocode a place name to geographic coordinates and bounding box. "
|
||||||
"Uses OpenStreetMap Nominatim."
|
"Uses OpenStreetMap Nominatim."
|
||||||
)
|
)
|
||||||
async def resolve_place(place: str) -> str:
|
async def resolve_place(place: str, ctx: Context) -> str:
|
||||||
"""Resolve a place name to coordinates and bounding box.
|
"""Resolve a place name to coordinates and bounding box.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
place: Place name (e.g. "Tokyo", "California", "Great Barrier Reef").
|
place: Place name (e.g. "Tokyo", "California", "Great Barrier Reef").
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
await ctx.report_progress(1, 2, "Geocoding...")
|
||||||
result = await client.resolve_place(place)
|
result = await client.resolve_place(place)
|
||||||
|
|
||||||
if result is None:
|
if result is None:
|
||||||
return f"Could not geocode '{place}'. Try a more specific name."
|
return f"Could not geocode '{place}'. Try a more specific name."
|
||||||
|
|
||||||
|
await ctx.report_progress(2, 2, "Complete")
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
"display_name": result.display_name,
|
"display_name": result.display_name,
|
||||||
@ -944,9 +1092,11 @@ def polar_watch(
|
|||||||
if pole.lower().startswith("ant"):
|
if pole.lower().startswith("ant"):
|
||||||
region = "Antarctic"
|
region = "Antarctic"
|
||||||
bbox = "[-180, -90, 180, -60]"
|
bbox = "[-180, -90, 180, -60]"
|
||||||
|
projection = "3031"
|
||||||
else:
|
else:
|
||||||
region = "Arctic"
|
region = "Arctic"
|
||||||
bbox = "[-180, 60, 180, 90]"
|
bbox = "[-180, 60, 180, 90]"
|
||||||
|
projection = "3413"
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f"Monitor {region} ice and snow conditions on {date}.",
|
f"Monitor {region} ice and snow conditions on {date}.",
|
||||||
@ -961,7 +1111,9 @@ def polar_watch(
|
|||||||
f"data exists for {date} on the selected layers.",
|
f"data exists for {date} on the selected layers.",
|
||||||
"",
|
"",
|
||||||
f"3. **Polar imagery**: Fetch imagery using the {region} "
|
f"3. **Polar imagery**: Fetch imagery using the {region} "
|
||||||
f"bounding box {bbox}. Get true color first, then ice/snow "
|
f"bounding box {bbox} with projection=\"{projection}\". "
|
||||||
|
f"This polar stereographic projection shows the region properly "
|
||||||
|
f"centered on the pole. Get true color first, then ice/snow "
|
||||||
f"concentration layers.",
|
f"concentration layers.",
|
||||||
"",
|
"",
|
||||||
"4. **Color interpretation**: Use explain_layer_colormap on each "
|
"4. **Color interpretation**: Use explain_layer_colormap on each "
|
||||||
@ -991,6 +1143,98 @@ def polar_watch(
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt
|
||||||
|
def quantitative_snapshot(
|
||||||
|
layer_id: str,
|
||||||
|
locations: str,
|
||||||
|
date: str,
|
||||||
|
) -> str:
|
||||||
|
"""Query exact data values at specific geographic coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer_id: GIBS layer identifier with a colormap
|
||||||
|
(e.g. "AIRS_L3_Surface_Air_Temperature_Daily_Day").
|
||||||
|
locations: Comma-separated place names (e.g. "Tokyo, Sydney, Sahara Desert").
|
||||||
|
date: Date to query (YYYY-MM-DD).
|
||||||
|
"""
|
||||||
|
location_list = [loc.strip() for loc in locations.split(",")]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Query the exact {layer_id} values at these locations on {date}: "
|
||||||
|
f"{', '.join(location_list)}.",
|
||||||
|
"",
|
||||||
|
"Follow this workflow:",
|
||||||
|
"",
|
||||||
|
f"1. **Understand the scale**: Use explain_layer_colormap on "
|
||||||
|
f'"{layer_id}" to learn the value range, units, and what '
|
||||||
|
f"different colors represent. Fetch the legend with get_legend.",
|
||||||
|
"",
|
||||||
|
"2. **Resolve coordinates**: Use resolve_place for each location "
|
||||||
|
"to get precise lat/lon coordinates.",
|
||||||
|
"",
|
||||||
|
"3. **Query each point**: Use query_point for each location "
|
||||||
|
"with the resolved coordinates. This returns the exact data "
|
||||||
|
"value by reverse-mapping the pixel color through the colormap.",
|
||||||
|
"",
|
||||||
|
"4. **Compare and contextualize**: Present the results in a table "
|
||||||
|
"with location, value (in native and converted units if applicable), "
|
||||||
|
"and a qualitative description. Explain why the values differ — "
|
||||||
|
"latitude, altitude, season, land use, proximity to water, etc.",
|
||||||
|
"",
|
||||||
|
"5. **Verify with imagery**: Optionally fetch get_imagery for one "
|
||||||
|
"location to visually confirm the data pattern matches the "
|
||||||
|
"point query results.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.prompt
|
||||||
|
def seasonal_timelapse(
|
||||||
|
layer_id: str,
|
||||||
|
location: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
) -> str:
|
||||||
|
"""Watch a location change across multiple dates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer_id: GIBS layer identifier (e.g. "MODIS_Terra_NDVI_8Day").
|
||||||
|
location: Place to observe.
|
||||||
|
start_date: Beginning of the period (YYYY-MM-DD).
|
||||||
|
end_date: End of the period (YYYY-MM-DD).
|
||||||
|
"""
|
||||||
|
lines = [
|
||||||
|
f"Show how {layer_id} changes at {location} "
|
||||||
|
f"from {start_date} to {end_date}.",
|
||||||
|
"",
|
||||||
|
"Follow this workflow:",
|
||||||
|
"",
|
||||||
|
f"1. **Layer info**: Use get_layer_info on \"{layer_id}\" to "
|
||||||
|
f"understand what it measures, its temporal period, and resolution.",
|
||||||
|
"",
|
||||||
|
"2. **Check dates**: Use check_layer_dates to verify data "
|
||||||
|
f"covers {start_date} through {end_date}.",
|
||||||
|
"",
|
||||||
|
"3. **Color interpretation**: Use explain_layer_colormap to "
|
||||||
|
"understand the value encoding, so you can describe changes "
|
||||||
|
"in scientific terms rather than just color names.",
|
||||||
|
"",
|
||||||
|
f"4. **Time series**: Use get_time_series to fetch imagery "
|
||||||
|
f'for "{location}" from {start_date} to {end_date}. '
|
||||||
|
f"This returns a sequence of images at regular intervals.",
|
||||||
|
"",
|
||||||
|
"5. **Narrate the progression**: For each frame, describe "
|
||||||
|
"what's visible and how it differs from the previous frame. "
|
||||||
|
"Reference colormap values to quantify the change. "
|
||||||
|
"Identify the overall trend and any notable transitions.",
|
||||||
|
"",
|
||||||
|
"6. **Bookend comparison**: Use compare_dates with "
|
||||||
|
f"{start_date} and {end_date} for a direct side-by-side "
|
||||||
|
"showing the total change across the period.",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
# Entry point
|
# Entry point
|
||||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
|||||||
@ -166,6 +166,8 @@ async def test_list_tools(capabilities_xml):
|
|||||||
"get_legend",
|
"get_legend",
|
||||||
"resolve_place",
|
"resolve_place",
|
||||||
"build_tile_url",
|
"build_tile_url",
|
||||||
|
"query_point",
|
||||||
|
"get_time_series",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in expected:
|
for name in expected:
|
||||||
@ -303,6 +305,8 @@ async def test_list_prompts(capabilities_xml):
|
|||||||
"layer_deep_dive",
|
"layer_deep_dive",
|
||||||
"multi_layer_story",
|
"multi_layer_story",
|
||||||
"polar_watch",
|
"polar_watch",
|
||||||
|
"quantitative_snapshot",
|
||||||
|
"seasonal_timelapse",
|
||||||
}
|
}
|
||||||
|
|
||||||
for name in expected:
|
for name in expected:
|
||||||
@ -358,3 +362,191 @@ async def test_colormap_resource(capabilities_xml, colormap_xml):
|
|||||||
finally:
|
finally:
|
||||||
await server_module._client.close()
|
await server_module._client.close()
|
||||||
server_module._client = None
|
server_module._client = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# query_point tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_fake_png(rgb: tuple[int, int, int], size: int = 3) -> bytes:
|
||||||
|
"""Create a tiny PNG with uniform color for mocking point queries."""
|
||||||
|
buf = BytesIO()
|
||||||
|
PILImage.new("RGBA", (size, size), (*rgb, 255)).save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_query_point(capabilities_xml, colormap_xml):
|
||||||
|
"""query_point reverse-maps pixel RGB through colormap to data value."""
|
||||||
|
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
||||||
|
return_value=httpx.Response(200, text=capabilities_xml)
|
||||||
|
)
|
||||||
|
# Return a 3x3 PNG with rgb (255,100,50) → maps to [290,300) K
|
||||||
|
respx.get(url__regex=r".*wms\.cgi.*").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
content=_make_fake_png((255, 100, 50)),
|
||||||
|
headers={"content-type": "image/png"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
respx.get(url__regex=r".*colormaps/v1\.3/.*\.xml").mock(
|
||||||
|
return_value=httpx.Response(200, text=colormap_xml)
|
||||||
|
)
|
||||||
|
|
||||||
|
server_module._client = await _init_mock_client(capabilities_xml)
|
||||||
|
try:
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"query_point",
|
||||||
|
{
|
||||||
|
"layer_id": "AIRS_L3_Surface_Air_Temperature_Daily_Day",
|
||||||
|
"date": "2025-06-01",
|
||||||
|
"lat": 35.67,
|
||||||
|
"lon": 139.65,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
data = json.loads(result.content[0].text)
|
||||||
|
|
||||||
|
assert data["lat"] == 35.67
|
||||||
|
assert data["lon"] == 139.65
|
||||||
|
assert data["quality"] == "exact" # exact RGB match
|
||||||
|
assert "value" in data
|
||||||
|
finally:
|
||||||
|
await server_module._client.close()
|
||||||
|
server_module._client = None
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_query_point_no_colormap(capabilities_xml):
|
||||||
|
"""query_point returns error for layers without colormaps."""
|
||||||
|
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
||||||
|
return_value=httpx.Response(200, text=capabilities_xml)
|
||||||
|
)
|
||||||
|
|
||||||
|
server_module._client = await _init_mock_client(capabilities_xml)
|
||||||
|
try:
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"query_point",
|
||||||
|
{
|
||||||
|
"layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor",
|
||||||
|
"date": "2025-06-01",
|
||||||
|
"lat": 35.67,
|
||||||
|
"lon": 139.65,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = result.content[0].text
|
||||||
|
assert "no colormap" in text
|
||||||
|
finally:
|
||||||
|
await server_module._client.close()
|
||||||
|
server_module._client = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_time_series tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_get_time_series(capabilities_xml):
|
||||||
|
"""get_time_series returns multiple dated images."""
|
||||||
|
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
||||||
|
return_value=httpx.Response(200, text=capabilities_xml)
|
||||||
|
)
|
||||||
|
respx.get(url__regex=r".*wms\.cgi.*").mock(
|
||||||
|
return_value=httpx.Response(
|
||||||
|
200,
|
||||||
|
content=_make_fake_jpeg(),
|
||||||
|
headers={"content-type": "image/jpeg"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
server_module._client = await _init_mock_client(capabilities_xml)
|
||||||
|
try:
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.call_tool(
|
||||||
|
"get_time_series",
|
||||||
|
{
|
||||||
|
"layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor",
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-06-01",
|
||||||
|
"bbox": [-120.0, 30.0, -110.0, 40.0],
|
||||||
|
"steps": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# 3 steps → 3 text labels + 3 images = 6 content items
|
||||||
|
texts = [c for c in result.content if c.type == "text"]
|
||||||
|
images = [c for c in result.content if c.type == "image"]
|
||||||
|
|
||||||
|
assert len(texts) == 3
|
||||||
|
assert len(images) == 3
|
||||||
|
assert "2025-01-01" in texts[0].text
|
||||||
|
assert "2025-06-01" in texts[-1].text
|
||||||
|
finally:
|
||||||
|
await server_module._client.close()
|
||||||
|
server_module._client = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# New prompt tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_quantitative_snapshot_prompt(capabilities_xml):
|
||||||
|
"""quantitative_snapshot prompt includes locations and tool references."""
|
||||||
|
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
||||||
|
return_value=httpx.Response(200, text=capabilities_xml)
|
||||||
|
)
|
||||||
|
|
||||||
|
server_module._client = await _init_mock_client(capabilities_xml)
|
||||||
|
try:
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.get_prompt(
|
||||||
|
"quantitative_snapshot",
|
||||||
|
{
|
||||||
|
"layer_id": "AIRS_L3_Surface_Air_Temperature_Daily_Day",
|
||||||
|
"locations": "Tokyo, Sydney",
|
||||||
|
"date": "2025-06-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = result.messages[0].content.text
|
||||||
|
|
||||||
|
assert "Tokyo" in text
|
||||||
|
assert "Sydney" in text
|
||||||
|
assert "query_point" in text
|
||||||
|
assert "explain_layer_colormap" in text
|
||||||
|
finally:
|
||||||
|
await server_module._client.close()
|
||||||
|
server_module._client = None
|
||||||
|
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_seasonal_timelapse_prompt(capabilities_xml):
|
||||||
|
"""seasonal_timelapse prompt includes dates and get_time_series reference."""
|
||||||
|
respx.get(url__regex=r".*WMTSCapabilities\.xml").mock(
|
||||||
|
return_value=httpx.Response(200, text=capabilities_xml)
|
||||||
|
)
|
||||||
|
|
||||||
|
server_module._client = await _init_mock_client(capabilities_xml)
|
||||||
|
try:
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
result = await client.get_prompt(
|
||||||
|
"seasonal_timelapse",
|
||||||
|
{
|
||||||
|
"layer_id": "MODIS_Terra_CorrectedReflectance_TrueColor",
|
||||||
|
"location": "Amazon Rainforest",
|
||||||
|
"start_date": "2025-01-01",
|
||||||
|
"end_date": "2025-12-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
text = result.messages[0].content.text
|
||||||
|
|
||||||
|
assert "Amazon Rainforest" in text
|
||||||
|
assert "get_time_series" in text
|
||||||
|
assert "2025-01-01" in text
|
||||||
|
assert "2025-12-01" in text
|
||||||
|
finally:
|
||||||
|
await server_module._client.close()
|
||||||
|
server_module._client = None
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user