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 |
|
||||
| `explain_layer_colormap` | Natural-language explanation of what colors represent |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@ -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.
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
| EPSG | Description | Use case |
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "mcgibs"
|
||||
version = "2026.02.18.4"
|
||||
version = "2026.02.19"
|
||||
description = "FastMCP server for NASA Global Imagery Browse Services (GIBS)"
|
||||
readme = "README.md"
|
||||
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 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 (
|
||||
COLORMAP_BASE,
|
||||
DEFAULT_EPSG,
|
||||
@ -296,6 +296,7 @@ class GIBSClient:
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
image_format: str = "image/jpeg",
|
||||
*,
|
||||
epsg: str = DEFAULT_EPSG,
|
||||
) -> bytes:
|
||||
"""Fetch a multi-layer WMS composite image."""
|
||||
@ -318,6 +319,7 @@ class GIBSClient:
|
||||
width: int = 1024,
|
||||
height: int = 512,
|
||||
image_format: str = "image/jpeg",
|
||||
epsg: str = DEFAULT_EPSG,
|
||||
) -> bytes:
|
||||
"""Fetch two images and compose a side-by-side comparison."""
|
||||
img_before = await self.get_wms_image(
|
||||
@ -327,6 +329,7 @@ class GIBSClient:
|
||||
width,
|
||||
height,
|
||||
image_format,
|
||||
epsg,
|
||||
)
|
||||
img_after = await self.get_wms_image(
|
||||
layer_id,
|
||||
@ -335,6 +338,7 @@ class GIBSClient:
|
||||
width,
|
||||
height,
|
||||
image_format,
|
||||
epsg,
|
||||
)
|
||||
|
||||
# M1: convert to RGB to avoid mode mismatch with RGBA PNGs
|
||||
@ -351,6 +355,66 @@ class GIBSClient:
|
||||
composite.save(buf, format="JPEG", quality=90)
|
||||
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) ---
|
||||
|
||||
async def describe_domains(
|
||||
|
||||
@ -484,3 +484,69 @@ def explain_colormap(colormap_set: ColorMapSet) -> str:
|
||||
lines.append(f"Transparent / no-data: {', '.join(nodata_entries)}")
|
||||
|
||||
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 json
|
||||
import logging
|
||||
from datetime import date as Date
|
||||
|
||||
import httpx
|
||||
from fastmcp import FastMCP
|
||||
@ -159,7 +160,7 @@ async def search_gibs_layers(
|
||||
description="Get detailed metadata for a specific GIBS layer including "
|
||||
"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.
|
||||
|
||||
Args:
|
||||
@ -170,7 +171,7 @@ async def get_layer_info(layer_id: str) -> str:
|
||||
if layer is None:
|
||||
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)
|
||||
|
||||
info = {
|
||||
@ -212,6 +213,7 @@ async def get_layer_info(layer_id: str) -> str:
|
||||
"north": layer.bbox.north,
|
||||
}
|
||||
|
||||
await ctx.report_progress(2, 2, "Complete")
|
||||
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.")
|
||||
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.
|
||||
|
||||
Args:
|
||||
@ -267,6 +269,7 @@ async def check_layer_dates(layer_id: str) -> str:
|
||||
lines.append(" No time dimension (static layer)")
|
||||
|
||||
# Try DescribeDomains for more precise info
|
||||
await ctx.report_progress(1, 2, "Querying live date range...")
|
||||
try:
|
||||
domains = await client.describe_domains(layer_id)
|
||||
if "time_domain" in domains:
|
||||
@ -274,6 +277,7 @@ async def check_layer_dates(layer_id: str) -> str:
|
||||
except (httpx.HTTPError, RuntimeError) as exc:
|
||||
log.debug("DescribeDomains failed for %s: %s", layer_id, exc)
|
||||
|
||||
await ctx.report_progress(2, 2, "Complete")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@ -300,6 +304,29 @@ async def _resolve_bbox(
|
||||
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(
|
||||
description="Fetch satellite imagery for a specific layer, date, and region. "
|
||||
"Provide either a place name or bbox coordinates. Returns the image."
|
||||
@ -313,6 +340,7 @@ async def get_imagery(
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
format: str = "jpeg",
|
||||
projection: str = "auto",
|
||||
):
|
||||
"""Fetch GIBS imagery via WMS.
|
||||
|
||||
@ -324,6 +352,7 @@ async def get_imagery(
|
||||
width: Image width in pixels.
|
||||
height: Image height in pixels.
|
||||
format: Image format — "jpeg" or "png".
|
||||
projection: EPSG code ("4326", "3857", "3413", "3031") or "auto" to detect from bbox.
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
@ -337,6 +366,8 @@ async def get_imagery(
|
||||
except Exception as exc:
|
||||
return str(exc)
|
||||
|
||||
epsg = _pick_projection(resolved_bbox, projection)
|
||||
|
||||
await ctx.report_progress(2, 4, "Fetching imagery from GIBS...")
|
||||
image_bytes = await client.get_wms_image(
|
||||
layer_id,
|
||||
@ -345,6 +376,7 @@ async def get_imagery(
|
||||
width,
|
||||
height,
|
||||
f"image/{format}",
|
||||
epsg,
|
||||
)
|
||||
|
||||
description = (
|
||||
@ -370,6 +402,7 @@ async def compare_dates(
|
||||
ctx: Context,
|
||||
bbox: list[float] | None = None,
|
||||
place: str | None = None,
|
||||
projection: str = "auto",
|
||||
):
|
||||
"""Side-by-side comparison of two dates.
|
||||
|
||||
@ -379,6 +412,7 @@ async def compare_dates(
|
||||
date_after: Later date (YYYY-MM-DD).
|
||||
bbox: Bounding box as [west, south, east, north].
|
||||
place: Place name to geocode.
|
||||
projection: EPSG code or "auto" to detect from bbox.
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
@ -392,6 +426,8 @@ async def compare_dates(
|
||||
except Exception as 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(3, 5, f"Fetching imagery for {date_after}...")
|
||||
composite_bytes = await client.compare_dates(
|
||||
@ -399,6 +435,8 @@ async def compare_dates(
|
||||
date_before,
|
||||
date_after,
|
||||
resolved_bbox,
|
||||
image_format="image/jpeg",
|
||||
epsg=epsg,
|
||||
)
|
||||
|
||||
description = (
|
||||
@ -423,6 +461,7 @@ async def get_imagery_composite(
|
||||
place: str | None = None,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
projection: str = "auto",
|
||||
):
|
||||
"""Multi-layer composite image.
|
||||
|
||||
@ -433,6 +472,7 @@ async def get_imagery_composite(
|
||||
place: Place name to geocode.
|
||||
width: Image width in pixels.
|
||||
height: Image height in pixels.
|
||||
projection: EPSG code or "auto" to detect from bbox.
|
||||
"""
|
||||
client = _get_client()
|
||||
|
||||
@ -445,6 +485,8 @@ async def get_imagery_composite(
|
||||
except Exception as exc:
|
||||
return str(exc)
|
||||
|
||||
epsg = _pick_projection(resolved_bbox, projection)
|
||||
|
||||
layer_names = ", ".join(layer_ids)
|
||||
await ctx.report_progress(2, 3, f"Fetching {len(layer_ids)}-layer composite...")
|
||||
image_bytes = await client.get_wms_composite(
|
||||
@ -453,6 +495,7 @@ async def get_imagery_composite(
|
||||
resolved_bbox,
|
||||
width,
|
||||
height,
|
||||
epsg=epsg,
|
||||
)
|
||||
|
||||
description = (
|
||||
@ -472,19 +515,23 @@ async def get_imagery_composite(
|
||||
description="Explain what the colors in a GIBS layer mean. "
|
||||
"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.
|
||||
|
||||
Args:
|
||||
layer_id: GIBS layer identifier.
|
||||
"""
|
||||
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.")
|
||||
async def get_legend(
|
||||
layer_id: str,
|
||||
ctx: Context,
|
||||
orientation: str = "horizontal",
|
||||
):
|
||||
"""Fetch the legend graphic for a layer.
|
||||
@ -494,14 +541,113 @@ async def get_legend(
|
||||
orientation: "horizontal" or "vertical".
|
||||
"""
|
||||
client = _get_client()
|
||||
await ctx.report_progress(1, 2, "Fetching legend...")
|
||||
legend_bytes = await client.get_legend_image(layer_id, orientation)
|
||||
|
||||
if legend_bytes is None:
|
||||
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")]
|
||||
|
||||
|
||||
@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
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
@ -511,18 +657,20 @@ async def get_legend(
|
||||
description="Geocode a place name to geographic coordinates and bounding box. "
|
||||
"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.
|
||||
|
||||
Args:
|
||||
place: Place name (e.g. "Tokyo", "California", "Great Barrier Reef").
|
||||
"""
|
||||
client = _get_client()
|
||||
await ctx.report_progress(1, 2, "Geocoding...")
|
||||
result = await client.resolve_place(place)
|
||||
|
||||
if result is None:
|
||||
return f"Could not geocode '{place}'. Try a more specific name."
|
||||
|
||||
await ctx.report_progress(2, 2, "Complete")
|
||||
return json.dumps(
|
||||
{
|
||||
"display_name": result.display_name,
|
||||
@ -944,9 +1092,11 @@ def polar_watch(
|
||||
if pole.lower().startswith("ant"):
|
||||
region = "Antarctic"
|
||||
bbox = "[-180, -90, 180, -60]"
|
||||
projection = "3031"
|
||||
else:
|
||||
region = "Arctic"
|
||||
bbox = "[-180, 60, 180, 90]"
|
||||
projection = "3413"
|
||||
|
||||
lines = [
|
||||
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"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.",
|
||||
"",
|
||||
"4. **Color interpretation**: Use explain_layer_colormap on each "
|
||||
@ -991,6 +1143,98 @@ def polar_watch(
|
||||
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
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@ -166,6 +166,8 @@ async def test_list_tools(capabilities_xml):
|
||||
"get_legend",
|
||||
"resolve_place",
|
||||
"build_tile_url",
|
||||
"query_point",
|
||||
"get_time_series",
|
||||
}
|
||||
|
||||
for name in expected:
|
||||
@ -303,6 +305,8 @@ async def test_list_prompts(capabilities_xml):
|
||||
"layer_deep_dive",
|
||||
"multi_layer_story",
|
||||
"polar_watch",
|
||||
"quantitative_snapshot",
|
||||
"seasonal_timelapse",
|
||||
}
|
||||
|
||||
for name in expected:
|
||||
@ -358,3 +362,191 @@ async def test_colormap_resource(capabilities_xml, colormap_xml):
|
||||
finally:
|
||||
await server_module._client.close()
|
||||
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